Skip to content

Commit 762161e

Browse files
KiaroSamaclaude
andcommitted
Run regression suites in parallel via a bounded test runner
Add tests/Invoke-RoboSyTests.ps1, which runs every tests/*.Tests.ps1 file concurrently as isolated child processes instead of sequentially. Each file still runs internally sequentially (they have intra-file order dependencies and their own counters) and each uses its own unique GUID sandbox, so the files share no fixture, port, database, account, or clipboard and are safe to parallelize. The runner is bounded and guarded per the global test rules: a resource-aware worker ceiling (-MaxWorkers, HOOKMAKER_MAX_TEST_WORKERS / ROBOSY_MAX_TEST_WORKERS, else max(2, cores-2), clamped to [1, min(fileCount, 8)]); per-file and whole-run wall timeouts that terminate the child process TREE with taskkill /T /F; and fail-closed behavior on zero discovered files, any non-zero child exit, a timeout, or a result-count mismatch. It uses [System.Diagnostics.Process] with async stdout/stderr draining (Start-Process -PassThru never populates ExitCode; ProcessStartInfo.ArgumentList is null under Windows PowerShell 5.1; a synchronous child-output read deadlocks on a full pipe buffer). CI (lint.yml) now runs the suite through the runner under both hosts and gained a timeout-minutes job ceiling. Measured ~2.2-2.4x faster locally (PS5.1 ~20.3s -> ~8.5s, PS7 ~25.5s -> ~11.6s, 16-core box, workers=5); all 205 assertions still pass on both hosts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c7ed0a5 commit 762161e

4 files changed

Lines changed: 232 additions & 38 deletions

File tree

‎.github/workflows/lint.yml‎

Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
analyze:
2626
name: PSScriptAnalyzer
2727
runs-on: windows-latest
28+
timeout-minutes: 15
2829
steps:
2930
- name: Checkout
3031
uses: actions/checkout@v7
@@ -63,44 +64,20 @@ jobs:
6364
}
6465
Write-Host "PSScriptAnalyzer found no issues."
6566
67+
# The parallel runner discovers every tests/*.Tests.ps1 file, runs them
68+
# concurrently as isolated child processes with a bounded worker ceiling
69+
# (auto: max(2, cores-2), clamped), enforces per-file and whole-run wall
70+
# timeouts, terminates process trees on timeout, propagates a non-zero exit
71+
# on any failure, and fails closed on zero discovered files. Each test file
72+
# still runs internally sequentially; only the files run in parallel.
6673
- name: Regression tests (Windows PowerShell 5.1)
6774
shell: powershell
6875
run: |
69-
$testFiles = @(Get-ChildItem -Path .\tests -Filter *.Tests.ps1 -File)
70-
if ($testFiles.Count -eq 0) {
71-
throw "No regression test files were found."
72-
}
73-
Write-Host ("Discovered {0} regression test file(s):" -f $testFiles.Count) -ForegroundColor Cyan
74-
$testFiles | ForEach-Object { Write-Host (" - {0}" -f $_.Name) -ForegroundColor Cyan }
75-
76-
$failed = $false
77-
foreach ($testFile in $testFiles) {
78-
Write-Host "== $($testFile.Name) (Windows PowerShell 5.1) ==" -ForegroundColor Cyan
79-
powershell.exe -NoProfile -ExecutionPolicy Bypass -File $testFile.FullName
80-
if ($LASTEXITCODE -ne 0) {
81-
$failed = $true
82-
Write-Host "$($testFile.Name) failed under Windows PowerShell 5.1 (exit code $LASTEXITCODE)." -ForegroundColor Red
83-
}
84-
}
85-
if ($failed) { throw "One or more regression test files failed under Windows PowerShell 5.1." }
76+
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\Invoke-RoboSyTests.ps1 -TestHost powershell.exe
77+
if ($LASTEXITCODE -ne 0) { throw "Regression tests failed under Windows PowerShell 5.1 (exit code $LASTEXITCODE)." }
8678
8779
- name: Regression tests (PowerShell 7+)
8880
shell: pwsh
8981
run: |
90-
$testFiles = @(Get-ChildItem -Path .\tests -Filter *.Tests.ps1 -File)
91-
if ($testFiles.Count -eq 0) {
92-
throw "No regression test files were found."
93-
}
94-
Write-Host ("Discovered {0} regression test file(s):" -f $testFiles.Count) -ForegroundColor Cyan
95-
$testFiles | ForEach-Object { Write-Host (" - {0}" -f $_.Name) -ForegroundColor Cyan }
96-
97-
$failed = $false
98-
foreach ($testFile in $testFiles) {
99-
Write-Host "== $($testFile.Name) (PowerShell 7+) ==" -ForegroundColor Cyan
100-
pwsh -NoProfile -File $testFile.FullName
101-
if ($LASTEXITCODE -ne 0) {
102-
$failed = $true
103-
Write-Host "$($testFile.Name) failed under PowerShell 7+ (exit code $LASTEXITCODE)." -ForegroundColor Red
104-
}
105-
}
106-
if ($failed) { throw "One or more regression test files failed under PowerShell 7+." }
82+
& pwsh -NoProfile -File .\tests\Invoke-RoboSyTests.ps1 -TestHost pwsh
83+
if ($LASTEXITCODE -ne 0) { throw "Regression tests failed under PowerShell 7+ (exit code $LASTEXITCODE)." }

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Added a new main-menu option `5` **Symlink Only**, which creates a symbolic link without ever moving anything. The order of the two entered paths does not matter: whichever side holds a real file/folder becomes the link target, and the other (missing, or an existing replaceable link) becomes the link. RoboSy stops without changing anything if both paths hold real items or if neither does. The link is created with the same rollback-safe, capability-preflighted, snapshot-verified machinery as Move + Symlink, including the directory junction fallback and the marker file at the real target.
88
- Added `tests/RoboSy.SymlinkOnly.Tests.ps1`, covering `Resolve-SymlinkOnlyDirection` direction detection (both orders, both-real, neither-real, reparse-point-as-link-side) and real interactive end-to-end scenarios for `Invoke-SymlinkOnlyJob`: create-only in both directions, the both-real/neither-real refusals, cancel, and the guarantee that the real item is never moved.
9+
- Added `tests/Invoke-RoboSyTests.ps1`, a bounded parallel test runner that runs every `tests/*.Tests.ps1` file concurrently as isolated child processes (each file still runs internally sequentially; every file uses its own disposable sandbox, so they share no state). It has a resource-aware worker ceiling (`-MaxWorkers`, `HOOKMAKER_MAX_TEST_WORKERS`/`ROBOSY_MAX_TEST_WORKERS`, or `max(2, cores-2)`, clamped), per-file and whole-run wall timeouts with process-tree termination, and fails closed on zero discovered files, any non-zero child exit, a timeout, or a result-count mismatch. `.github/workflows/lint.yml` now runs the regression suite through this runner (cutting local test wall time roughly in half) and gained a `timeout-minutes` job ceiling.
910

1011
### Fixed
1112

‎README.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ Runtime logs are ignored by Git and should not be published.
237237
| `tests/RoboSy.Classification.Tests.ps1` | Regression tests for final-path classification, type conflicts, reparse-point hardening, and execution-time revalidation. |
238238
| `tests/RoboSy.Input.Tests.ps1` | Regression tests for `Read-ConsoleText`'s redirected/non-redirected input paths and the `Read-HostUiLine` fallback chain. |
239239
| `tests/RoboSy.SymlinkOnly.Tests.ps1` | Regression tests for Symlink Only direction detection and its create-only, never-move end-to-end behavior. |
240+
| `tests/Invoke-RoboSyTests.ps1` | Bounded parallel test runner (runs every `*.Tests.ps1` concurrently as isolated child processes); used locally and in CI. |
240241
| `RoboSy.cmd` | Normal launcher. |
241242
| `RoboSy Admin.cmd` | Administrator launcher. |
242243
| `Install-RoboSyPath.ps1` | Adds RoboSy to the user `PATH` and installs the command shim. |
@@ -285,14 +286,18 @@ Invoke-ScriptAnalyzer -Path . -Recurse -Settings .\PSScriptAnalyzerSettings.psd1
285286

286287
A clean run reports no issues.
287288

288-
Run the regression tests locally with either PowerShell version:
289+
Run the regression tests locally with the parallel runner (this is what CI uses). It runs every `tests/*.Tests.ps1` file concurrently as isolated child processes, with a bounded worker ceiling and per-file/whole-run timeouts:
289290

290291
```powershell
291-
Get-ChildItem .\tests -Filter *.Tests.ps1 | ForEach-Object { powershell.exe -NoProfile -ExecutionPolicy Bypass -File $_.FullName }
292-
Get-ChildItem .\tests -Filter *.Tests.ps1 | ForEach-Object { pwsh -NoProfile -File $_.FullName }
292+
# Windows PowerShell 5.1
293+
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\Invoke-RoboSyTests.ps1 -TestHost powershell.exe
294+
# PowerShell 7+
295+
pwsh -NoProfile -File .\tests\Invoke-RoboSyTests.ps1 -TestHost pwsh
293296
```
294297

295-
Each test file prints its own passed/failed/skipped counts and exits non-zero on any failure. Tests run entirely inside disposable temporary directories and clean up after themselves; the interactive end-to-end scenarios in `RoboSy.LinkSafety.Tests.ps1` and `RoboSy.Classification.Tests.ps1` drive a disposable copy of `RoboSy.ps1` through piped input, so its own log directory never touches the real repository. `RoboSy.Input.Tests.ps1` covers `Read-ConsoleText`'s redirected and non-redirected input paths, including the `Read-HostUiLine` host-line-reader fallback chain, using injected fakes rather than a real keyboard.
298+
The runner prints each file's passed/failed/skipped counts and total wall time, and exits non-zero on any failure, timeout, or if zero test files are discovered. The worker count defaults to `max(2, cores-2)` (clamped to the number of test files, max 8) and can be capped with `-MaxWorkers N` or the `HOOKMAKER_MAX_TEST_WORKERS` / `ROBOSY_MAX_TEST_WORKERS` environment variable. Only the files run in parallel — each file stays internally sequential, and every file uses its own unique temporary sandbox, so they share no state. You can still run a single file directly (for example `pwsh -NoProfile -File .\tests\RoboSy.Input.Tests.ps1`) when debugging one suite.
299+
300+
Tests run entirely inside disposable temporary directories and clean up after themselves; the interactive end-to-end scenarios in `RoboSy.LinkSafety.Tests.ps1`, `RoboSy.Classification.Tests.ps1`, and `RoboSy.SymlinkOnly.Tests.ps1` drive a disposable copy of `RoboSy.ps1` through piped input, so its own log directory never touches the real repository. `RoboSy.Input.Tests.ps1` covers `Read-ConsoleText`'s redirected and non-redirected input paths, including the `Read-HostUiLine` host-line-reader fallback chain, using injected fakes rather than a real keyboard.
296301

297302
### Manual terminal smoke test
298303

‎tests/Invoke-RoboSyTests.ps1‎

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# RoboSy parallel test runner
2+
# SPDX-License-Identifier: MIT
3+
# Copyright (c) 2026 Kiaro Sama
4+
#
5+
# Runs every tests/*.Tests.ps1 file concurrently as separate child processes,
6+
# with a bounded, resource-aware worker ceiling and outer wall timeouts. Each
7+
# test FILE stays internally sequential (they have intra-file order dependencies
8+
# and their own $script:Passed counters); only the files run in parallel. Every
9+
# file uses its own unique GUID sandbox (New-Sandbox), so they share no mutable
10+
# fixture, port, database, account, or clipboard and are safe to run at once.
11+
#
12+
# Fail-closed: zero discovered files, any non-zero child exit, a per-file or
13+
# whole-run timeout, or fewer results than discovered files all exit 1.
14+
#
15+
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\tests\Invoke-RoboSyTests.ps1
16+
# pwsh -NoProfile -File .\tests\Invoke-RoboSyTests.ps1 -TestHost pwsh
17+
#
18+
# Worker ceiling (first that is set wins): -MaxWorkers, $env:HOOKMAKER_MAX_TEST_WORKERS,
19+
# $env:ROBOSY_MAX_TEST_WORKERS, else max(2, cores-2). Always clamped to
20+
# [1, min(fileCount, 8)] so it never oversubscribes the machine.
21+
22+
[CmdletBinding()]
23+
param(
24+
[string]$TestHost = $(if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell.exe' }),
25+
[int]$MaxWorkers = 0,
26+
[int]$TimeoutSeconds = 300,
27+
[int]$PerFileTimeoutSeconds = 180,
28+
[string]$TestsDirectory = ''
29+
)
30+
31+
$ErrorActionPreference = 'Stop'
32+
33+
if ([string]::IsNullOrWhiteSpace($TestsDirectory)) {
34+
$TestsDirectory = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) { $PSScriptRoot } else { (Get-Location).Path }
35+
}
36+
37+
# --- Discover suites (fail closed on zero, matching the CI guard) ---
38+
$testFiles = @(Get-ChildItem -Path $TestsDirectory -Filter '*.Tests.ps1' -File | Sort-Object Name)
39+
if ($testFiles.Count -eq 0) {
40+
Write-Host ("No regression test files were found under: {0}" -f $TestsDirectory) -ForegroundColor Red
41+
exit 1
42+
}
43+
44+
# --- Resolve the child test host to an absolute path (safe arg passing) ---
45+
$resolvedHost = Get-Command $TestHost -ErrorAction SilentlyContinue
46+
$hostPath = if ($resolvedHost) { $resolvedHost.Source } else { $TestHost }
47+
48+
# --- Worker ceiling: one documented shared cap, clamped and resource-aware ---
49+
$ceiling = $env:HOOKMAKER_MAX_TEST_WORKERS
50+
if ([string]::IsNullOrWhiteSpace($ceiling)) { $ceiling = $env:ROBOSY_MAX_TEST_WORKERS }
51+
if ($MaxWorkers -le 0) {
52+
$parsed = 0
53+
if (-not [string]::IsNullOrWhiteSpace($ceiling) -and [int]::TryParse($ceiling, [ref]$parsed) -and $parsed -gt 0) {
54+
$MaxWorkers = $parsed
55+
}
56+
else {
57+
$MaxWorkers = [Math]::Max(2, [Environment]::ProcessorCount - 2)
58+
}
59+
}
60+
$MaxWorkers = [Math]::Max(1, [Math]::Min($MaxWorkers, [Math]::Min($testFiles.Count, 8)))
61+
62+
Write-Host ("Discovered {0} regression test file(s); host={1}; workers={2}; wall<={3}s per-file<={4}s" -f `
63+
$testFiles.Count, [System.IO.Path]::GetFileName($hostPath), $MaxWorkers, $TimeoutSeconds, $PerFileTimeoutSeconds) -ForegroundColor Cyan
64+
$testFiles | ForEach-Object { Write-Host (" - {0}" -f $_.Name) -ForegroundColor Cyan }
65+
66+
# taskkill /T terminates the whole owned process tree (robocopy / cmd / spawned
67+
# interactive hosts), which killing the parent alone would leave behind on
68+
# Windows. Fully swallows output/errors and no-ops if the process already exited
69+
# (a normal race during cleanup), so it can never abort the run.
70+
function Stop-ProcessTree {
71+
param([int]$ProcessId)
72+
try {
73+
if ($null -ne (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue)) {
74+
& taskkill.exe /PID $ProcessId /T /F *> $null
75+
}
76+
}
77+
catch { }
78+
}
79+
80+
# Start one test file as an isolated child process. Uses [Diagnostics.Process]
81+
# (not Start-Process, whose -PassThru object never populates ExitCode) with the
82+
# .Arguments string (ProcessStartInfo.ArgumentList is null under Windows
83+
# PowerShell 5.1). Test paths contain spaces but no embedded quotes, so simple
84+
# double-quote wrapping is safe. stdout/stderr are drained asynchronously so a
85+
# chatty child (hundreds of lines) can never deadlock on a full pipe buffer.
86+
function Start-TestProcess {
87+
param([System.IO.FileInfo]$File, [string]$HostPath)
88+
89+
$psi = New-Object System.Diagnostics.ProcessStartInfo
90+
$psi.FileName = $HostPath
91+
$psi.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f $File.FullName
92+
$psi.UseShellExecute = $false
93+
$psi.RedirectStandardOutput = $true
94+
$psi.RedirectStandardError = $true
95+
$psi.RedirectStandardInput = $true
96+
$psi.CreateNoWindow = $true
97+
98+
$proc = [System.Diagnostics.Process]::Start($psi)
99+
$proc.StandardInput.Close() # detached, non-interactive stdin
100+
101+
return @{
102+
Proc = $proc
103+
File = $File
104+
OutTask = $proc.StandardOutput.ReadToEndAsync()
105+
ErrTask = $proc.StandardError.ReadToEndAsync()
106+
Sw = [System.Diagnostics.Stopwatch]::StartNew()
107+
}
108+
}
109+
110+
$queue = [System.Collections.Generic.Queue[object]]::new()
111+
$testFiles | ForEach-Object { $queue.Enqueue($_) }
112+
$running = @{}
113+
$results = New-Object System.Collections.Generic.List[object]
114+
$overall = [System.Diagnostics.Stopwatch]::StartNew()
115+
116+
try {
117+
while ($queue.Count -gt 0 -or $running.Count -gt 0) {
118+
while ($running.Count -lt $MaxWorkers -and $queue.Count -gt 0) {
119+
$state = Start-TestProcess -File $queue.Dequeue() -HostPath $hostPath
120+
$running[$state.Proc.Id] = $state
121+
}
122+
123+
Start-Sleep -Milliseconds 200
124+
125+
foreach ($childId in @($running.Keys)) {
126+
$state = $running[$childId]
127+
$proc = $state.Proc
128+
129+
if ($proc.HasExited) {
130+
$proc.WaitForExit() # ensure the async read tasks have reached EOF
131+
$state.Sw.Stop()
132+
$out = $state.OutTask.Result
133+
$err = $state.ErrTask.Result
134+
$summaryLine = (($out -split "`n") | Where-Object { $_ -match 'Passed:' } | Select-Object -Last 1)
135+
$results.Add([pscustomobject]@{
136+
Name = $state.File.Name
137+
Exit = $proc.ExitCode
138+
Seconds = $state.Sw.Elapsed.TotalSeconds
139+
Summary = ([string]$summaryLine).Trim()
140+
Output = $out
141+
Error = $err
142+
})
143+
$running.Remove($childId)
144+
continue
145+
}
146+
147+
# Per-file wall ceiling.
148+
if ($state.Sw.Elapsed.TotalSeconds -gt $PerFileTimeoutSeconds) {
149+
Stop-ProcessTree -ProcessId $childId
150+
$state.Sw.Stop()
151+
$results.Add([pscustomobject]@{
152+
Name = $state.File.Name
153+
Exit = 124
154+
Seconds = $state.Sw.Elapsed.TotalSeconds
155+
Summary = ("PER-FILE TIMEOUT (> {0}s)" -f $PerFileTimeoutSeconds)
156+
Output = ''
157+
Error = ''
158+
})
159+
$running.Remove($childId)
160+
}
161+
}
162+
163+
# Whole-run wall ceiling.
164+
if ($overall.Elapsed.TotalSeconds -gt $TimeoutSeconds) {
165+
foreach ($childId in @($running.Keys)) {
166+
Stop-ProcessTree -ProcessId $childId
167+
$state = $running[$childId]
168+
$state.Sw.Stop()
169+
$results.Add([pscustomobject]@{
170+
Name = $state.File.Name
171+
Exit = 124
172+
Seconds = $state.Sw.Elapsed.TotalSeconds
173+
Summary = ("WALL TIMEOUT (run exceeded {0}s)" -f $TimeoutSeconds)
174+
Output = ''
175+
Error = ''
176+
})
177+
$running.Remove($childId)
178+
}
179+
break
180+
}
181+
}
182+
}
183+
finally {
184+
foreach ($childId in @($running.Keys)) { Stop-ProcessTree -ProcessId $childId }
185+
}
186+
$overall.Stop()
187+
188+
# --- Report ---
189+
Write-Host ''
190+
$anyFail = $false
191+
foreach ($r in ($results | Sort-Object Name)) {
192+
$color = if ($r.Exit -eq 0) { 'Green' } else { 'Red' }
193+
Write-Host (" {0,-38} {1,7:N1}s exit={2} {3}" -f $r.Name, $r.Seconds, $r.Exit, $r.Summary) -ForegroundColor $color
194+
if ($r.Exit -ne 0) {
195+
$anyFail = $true
196+
if (-not [string]::IsNullOrWhiteSpace($r.Output)) { Write-Host $r.Output }
197+
if (-not [string]::IsNullOrWhiteSpace($r.Error)) { Write-Host $r.Error -ForegroundColor Red }
198+
}
199+
}
200+
Write-Host ''
201+
Write-Host ("Total: {0} file(s) in {1:N1}s (workers={2}, host={3})" -f `
202+
$results.Count, $overall.Elapsed.TotalSeconds, $MaxWorkers, [System.IO.Path]::GetFileName($hostPath)) `
203+
-ForegroundColor $(if ($anyFail) { 'Red' } else { 'Green' })
204+
205+
# Coverage guard: every discovered suite must have produced a result.
206+
if ($results.Count -ne $testFiles.Count) {
207+
Write-Host ("Coverage error: discovered {0} suite(s) but recorded {1} result(s)." -f $testFiles.Count, $results.Count) -ForegroundColor Red
208+
exit 1
209+
}
210+
if ($anyFail) { exit 1 }
211+
exit 0

0 commit comments

Comments
 (0)