From 55080f7052e3dafe1590273960a7e291d9cd2cb6 Mon Sep 17 00:00:00 2001 From: BiosSystem <63607038+BiosSystem@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:48:58 +0300 Subject: [PATCH] Add a launcher that runs the Sandbox suite from any clone Winnow-Tests.wsb has to carry absolute HostFolder paths, so it only works where the repository sits at exactly that path; on the maintainer's machine it did not. Invoke-SandboxRun.ps1 writes the same configuration for wherever the clone lives, starts Windows Sandbox, waits for the harness inside it, prints the summary, and exits 0/1/2 for passed/failed/did not run. It refuses to start while a sandbox window is open and waits for a closing VM to exit first: a sandbox started during the previous one's shutdown can boot without its mapped folders, and the harness then never runs. Verified end to end: 30 passed, 0 failed, 1 skipped by design; with -CloseWhenDone the sandbox is closed and the temporary .wsb removed. --- Tests/Integration/README.md | 19 ++- .../Integration/Sandbox/Invoke-SandboxRun.ps1 | 160 ++++++++++++++++++ Tests/Integration/Sandbox/Winnow-Tests.wsb | 4 + 3 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 Tests/Integration/Sandbox/Invoke-SandboxRun.ps1 diff --git a/Tests/Integration/README.md b/Tests/Integration/README.md index ce35bd3..a0ebbf0 100644 --- a/Tests/Integration/README.md +++ b/Tests/Integration/README.md @@ -36,10 +36,21 @@ Adding the dry-run checks, on a machine you can throw away: .\Tests\Integration\Invoke-IntegrationTests.ps1 -Ephemeral ``` -The full suite runs in Windows Sandbox. Open `Sandbox\Winnow-Tests.wsb`: it -maps the repository read-only, maps `Sandbox\results` writable, installs Pester, -and runs everything inside the sandbox. Edit both `HostFolder` paths in that file -if the repository is not at the default path. +The full suite runs in Windows Sandbox. The simplest way is the launcher, from a +normal (non-elevated) PowerShell at the repository root: + +```powershell +.\Tests\Integration\Sandbox\Invoke-SandboxRun.ps1 -CloseWhenDone +``` + +It writes a sandbox configuration for wherever this clone lives, starts Windows +Sandbox, waits for the suite inside it to finish, prints the result, and exits +`0` when everything passed, `1` when a test failed, and `2` when the run did not +start or finish. The repository is mapped read-only and only `Sandbox\results` +is writable, so the mutating tests can only change the disposable sandbox. + +`Sandbox\Winnow-Tests.wsb` does the same by hand: open it to start a run. Its +two `HostFolder` paths are absolute, so edit them to point at your clone first. **Windows Sandbox has to be enabled first.** It ships with Windows 11 Pro and Enterprise but is off by default. From an elevated PowerShell: diff --git a/Tests/Integration/Sandbox/Invoke-SandboxRun.ps1 b/Tests/Integration/Sandbox/Invoke-SandboxRun.ps1 new file mode 100644 index 0000000..85625df --- /dev/null +++ b/Tests/Integration/Sandbox/Invoke-SandboxRun.ps1 @@ -0,0 +1,160 @@ +<# +.SYNOPSIS + Runs the full integration suite, mutating tests included, in Windows Sandbox + and prints the result. + +.DESCRIPTION + The committed Winnow-Tests.wsb has to carry absolute HostFolder paths, so it + only works on a machine where the repository sits at exactly that path. This + script writes an equivalent .wsb for wherever this clone lives, starts + Windows Sandbox with it, waits for the harness inside the sandbox + (Start-SandboxRun.ps1) to finish, and prints integration-summary.json. + + The repository is mapped read-only and only Tests\Integration\Sandbox\results + is writable, exactly as in Winnow-Tests.wsb. Everything the mutating tests + change happens inside the disposable sandbox. + + Only one sandbox can run at a time, and a sandbox started while the previous + one's VM is still shutting down can boot without its mapped folders, in which + case the harness never runs. So this refuses to start while a sandbox window + is open, and waits for a closing VM to exit before launching. + +.PARAMETER TimeoutMinutes + How long to wait for the suite to finish once the harness has started. + +.PARAMETER CloseWhenDone + Close the sandbox once the results are in. By default it is left open so its + console can still be read; close it before running this again. + +.OUTPUTS + Exit code 0 when every test passed, 1 when any test failed, 2 when the + harness did not start or did not finish in time. + +.EXAMPLE + .\Tests\Integration\Sandbox\Invoke-SandboxRun.ps1 -CloseWhenDone +#> +[CmdletBinding()] +param( + [int]$TimeoutMinutes = 30, + [switch]$CloseWhenDone +) + +$ErrorActionPreference = 'Stop' + +$sandboxExe = Join-Path $env:WINDIR 'System32\WindowsSandbox.exe' +if (-not (Test-Path -LiteralPath $sandboxExe)) { + throw 'Windows Sandbox is not enabled. Enable the Containers-DisposableClientVM optional feature and restart; see Tests\Integration\README.md.' +} + +$sandboxDir = $PSScriptRoot +$repoRoot = (Resolve-Path (Join-Path $sandboxDir '..\..\..')).Path +$resultsDir = Join-Path $sandboxDir 'results' +if (-not (Test-Path -LiteralPath $resultsDir)) { + New-Item -ItemType Directory -Path $resultsDir -Force | Out-Null +} + +$windowProcesses = @('WindowsSandboxRemoteSession', 'WindowsSandboxServer') +if (@(Get-Process -Name $windowProcesses -ErrorAction SilentlyContinue).Count -gt 0) { + throw 'A Windows Sandbox is already running. Close it, then run this again.' +} + +# A VM that is still shutting down has no window left, only its memory process. +$teardownDeadline = (Get-Date).AddMinutes(10) +if (@(Get-Process -Name 'vmmemWindowsSandbox' -ErrorAction SilentlyContinue).Count -gt 0) { + Write-Host 'Waiting for the previous sandbox to finish shutting down...' + while ((Get-Date) -lt $teardownDeadline -and @(Get-Process -Name 'vmmemWindowsSandbox' -ErrorAction SilentlyContinue).Count -gt 0) { + Start-Sleep -Seconds 5 + } + Start-Sleep -Seconds 30 +} + +$escape = { param($text) [System.Security.SecurityElement]::Escape($text) } +$wsb = @" + + + + $(& $escape $repoRoot) + C:\Winnow + true + + + $(& $escape $resultsDir) + C:\Winnow-Results + false + + + + powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Winnow\Tests\Integration\Sandbox\Start-SandboxRun.ps1 + + 4096 + Enable + +"@ +$wsbPath = Join-Path $env:TEMP ('Winnow-Tests-{0}.wsb' -f [guid]::NewGuid().ToString('N')) +Set-Content -LiteralPath $wsbPath -Value $wsb -Encoding UTF8 + +$summaryPath = Join-Path $resultsDir 'integration-summary.json' +$transcriptPath = Join-Path $resultsDir 'sandbox-transcript.log' +$bootstrapPath = Join-Path $resultsDir 'bootstrap-error.json' + +function Test-WrittenSince { + param([string]$Path, [datetime]$Since) + (Test-Path -LiteralPath $Path) -and ((Get-Item -LiteralPath $Path).LastWriteTime -gt $Since) +} + +try { + $launch = Get-Date + Write-Host "Starting Windows Sandbox for $repoRoot" + Start-Process -FilePath $sandboxExe -ArgumentList ('"{0}"' -f $wsbPath) + + # The harness writes its transcript within seconds of the sandbox logging on. + $startDeadline = (Get-Date).AddMinutes(6) + while ((Get-Date) -lt $startDeadline -and -not (Test-WrittenSince -Path $transcriptPath -Since $launch)) { + Start-Sleep -Seconds 10 + } + if (-not (Test-WrittenSince -Path $transcriptPath -Since $launch)) { + Write-Host 'The test harness did not start inside the sandbox. Check the sandbox window.' -ForegroundColor Red + exit 2 + } + Write-Host 'Harness started. Running the suite...' + + $finishDeadline = (Get-Date).AddMinutes($TimeoutMinutes) + while ((Get-Date) -lt $finishDeadline -and -not (Test-WrittenSince -Path $summaryPath -Since $launch)) { + Start-Sleep -Seconds 15 + } + if (-not (Test-WrittenSince -Path $summaryPath -Since $launch)) { + Write-Host "No results after $TimeoutMinutes minutes. The transcript is at $transcriptPath." -ForegroundColor Red + exit 2 + } + Start-Sleep -Seconds 2 + + $summary = Get-Content -LiteralPath $summaryPath -Raw | ConvertFrom-Json + Write-Host '' + if ($summary.PSObject.Properties['BootstrapError']) { + Write-Host "The harness failed before the suite ran: $($summary.BootstrapError)" -ForegroundColor Red + exit 2 + } + + $color = if ([int]$summary.Failed -eq 0) { 'Green' } else { 'Red' } + Write-Host ("Integration suite: {0} passed, {1} failed, {2} skipped, {3} total." -f + $summary.Passed, $summary.Failed, $summary.Skipped, $summary.Total) -ForegroundColor $color + foreach ($failure in @($summary.Failures)) { + Write-Host " FAILED: $($failure.Name)" -ForegroundColor Red + Write-Host " $(($failure.Message -split "`n")[0])" + } + foreach ($block in @($summary.BlockFailures)) { + Write-Host " SETUP FAILED: $($block.Name): $($block.Message)" -ForegroundColor Red + } + if (Test-WrittenSince -Path $bootstrapPath -Since $launch) { + Write-Host " The harness also reported an error after the suite: see $bootstrapPath" -ForegroundColor Yellow + } + Write-Host "Results: $resultsDir" + + if ([int]$summary.Failed -eq 0) { exit 0 } else { exit 1 } +} +finally { + Remove-Item -LiteralPath $wsbPath -Force -ErrorAction SilentlyContinue + if ($CloseWhenDone) { + Get-Process -Name $windowProcesses -ErrorAction SilentlyContinue | Stop-Process -Force + } +} diff --git a/Tests/Integration/Sandbox/Winnow-Tests.wsb b/Tests/Integration/Sandbox/Winnow-Tests.wsb index b81cd10..f8564c1 100644 --- a/Tests/Integration/Sandbox/Winnow-Tests.wsb +++ b/Tests/Integration/Sandbox/Winnow-Tests.wsb @@ -7,6 +7,10 @@ survive: everything else inside the sandbox is destroyed when the window closes, including the console output. + Easier: run Invoke-SandboxRun.ps1 next to this file. It writes this same + configuration with the paths of your clone, starts the sandbox, and prints + the result, so nothing here needs editing. + The two HostFolder paths below are the maintainer's local example and are almost certainly wrong for your machine. They are absolute and machine specific, so if you cloned this repository you MUST edit both to point at