diff --git a/AGENTS.md b/AGENTS.md index 220fcdc8..9842191c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,8 @@ dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj ``` +On Windows, prefer `scripts/Test-Hidden.ps1 ` for WinForms tests: it runs them on a hidden desktop so no windows appear or take focus; see [hidden-desktop tests](docs/hidden-desktop-tests.md). + Only Plan Cutting changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); never run it for nesting engines, fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); `part-order` and `entries` plan every nest. No plate may rank below its baseline, and none may join or climb the near-budget list compared with master's run. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`. Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror. diff --git a/docs/hidden-desktop-tests.md b/docs/hidden-desktop-tests.md new file mode 100644 index 00000000..36d61bdc --- /dev/null +++ b/docs/hidden-desktop-tests.md @@ -0,0 +1,43 @@ +# Hidden-desktop test runs + +WinForms tests create real windows, which appear on the screen and take focus +from whatever you are doing. `scripts/Test-Hidden.ps1` runs `dotnet test` on a +separate Win32 desktop instead, so nothing appears and focus stays put. + +```powershell +# OpenNest.WinForms.Tests +.\scripts\Test-Hidden.ps1 + +# Any dotnet test arguments, passed through unchanged +.\scripts\Test-Hidden.ps1 OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj --no-build --filter FullyQualifiedName~DrawingLibrary + +# Allow longer than the default 30 minutes +.\scripts\Test-Hidden.ps1 -HiddenTimeoutMinutes 60 +``` + +Output streams back as usual and the exit code is that of `dotnet test`, or 124 +after a timeout. It needs no feature, setting or administrator rights. + +## How it behaves + +- The desktop is created in the current window station, so forms get real + window handles, message loops, layout and `DrawToBitmap`. Focus also works + within the desktop: the focus-traversal tests pass unchanged. +- Nobody can see or dismiss a window there. A test stuck on a modal dialog stops + at the timeout, and the error says so. +- The run lives in a job object, so stopping the script or reaching the timeout + ends the whole process tree. MSBuild node reuse and the shared compiler are + turned off for the run, so a build inside it can be slower; use `--no-build` + after building normally. +- Whatever a test hands to the shell, such as opening a file through + `UseShellExecute`, can be started by Explorer and appear on your desktop. The + WinForms suite does not do this. + +## Alternatives + +- **Windows Sandbox**: isolates more, but a user must enable the optional + feature, and each sandbox starts empty. A `.wsb` file would have to map the + repository and an SDK folder and then start from cold every run. +- **CI or a remote runner**: suits full verification and needs no local desktop, + but a push-and-wait cycle is too slow for iterating on a dialog, and a runner + must have an interactive session for its windows to behave. diff --git a/scripts/Test-Hidden.ps1 b/scripts/Test-Hidden.ps1 new file mode 100644 index 00000000..13b11372 --- /dev/null +++ b/scripts/Test-Hidden.ps1 @@ -0,0 +1,293 @@ +<# +.SYNOPSIS + Runs dotnet test on a hidden Win32 desktop so test windows cannot appear + on, or take focus from, the interactive desktop. + +.DESCRIPTION + Every argument except -HiddenTimeoutMinutes goes to dotnet test unchanged. + With none, runs OpenNest.WinForms.Tests. Output streams back here and the + script exits with the dotnet test exit code, or 124 after a timeout. + See docs/hidden-desktop-tests.md. + +.EXAMPLE + .\scripts\Test-Hidden.ps1 + +.EXAMPLE + .\scripts\Test-Hidden.ps1 OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj --no-build --filter FullyQualifiedName~DrawingLibrary +#> +# No param block: it would bind the first dotnet argument by position, and +# dotnet's short switches (-v, -e) by prefix. Everything is read from $args. + +$ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +public sealed class HiddenDesktopRun : IDisposable +{ + private const uint GenericAll = 0x10000000; + private const uint CreateSuspended = 0x00000004; + private const uint CreateNoWindow = 0x08000000; + private const uint CreateUnicodeEnvironment = 0x00000400; + private const uint StartfUseStdHandles = 0x00000100; + private const uint HandleFlagInherit = 1; + private const uint JobObjectLimitKillOnJobClose = 0x00002000; + private const int JobObjectExtendedLimitInformation = 9; + private const uint WaitObject0 = 0; + + private IntPtr desktop; + private IntPtr job; + private IntPtr process; + + public HiddenDesktopRun(string commandLine, string workingDirectory, string logPath) + { + var desktopName = "OpenNestTests-" + Guid.NewGuid().ToString("N"); + try + { + desktop = CreateDesktopW(desktopName, IntPtr.Zero, IntPtr.Zero, 0, GenericAll, IntPtr.Zero); + if (desktop == IntPtr.Zero) + throw new Win32Exception(); + + // Closing the job ends the whole tree, however the script stops. + job = CreateJobObjectW(IntPtr.Zero, null); + if (job == IntPtr.Zero) + throw new Win32Exception(); + var limits = new JobExtendedLimits(); + limits.Basic.LimitFlags = JobObjectLimitKillOnJobClose; + if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref limits, Marshal.SizeOf(limits))) + throw new Win32Exception(); + + using (var log = new FileStream(logPath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) + { + var logHandle = log.SafeFileHandle.DangerousGetHandle(); + if (!SetHandleInformation(logHandle, HandleFlagInherit, HandleFlagInherit)) + throw new Win32Exception(); + + var startup = new StartupInfo + { + Size = Marshal.SizeOf(typeof(StartupInfo)), + Desktop = @"WinSta0\" + desktopName, + Flags = StartfUseStdHandles, + StdOutput = logHandle, + StdError = logHandle, + }; + ProcessInformation info; + if (!CreateProcessW(null, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, true, + CreateSuspended | CreateNoWindow | CreateUnicodeEnvironment, IntPtr.Zero, workingDirectory, + ref startup, out info)) + throw new Win32Exception(); + + process = info.Process; + try + { + if (!AssignProcessToJobObject(job, process)) + throw new Win32Exception(); + if (ResumeThread(info.Thread) == uint.MaxValue) + throw new Win32Exception(); + } + finally + { + CloseHandle(info.Thread); + } + } + } + catch + { + Dispose(); + throw; + } + } + + public bool WaitForExit(int milliseconds) + { + return WaitForSingleObject(process, (uint)milliseconds) == WaitObject0; + } + + public int ExitCode + { + get + { + int code; + if (!GetExitCodeProcess(process, out code)) + throw new Win32Exception(); + return code; + } + } + + public void Dispose() + { + if (job != IntPtr.Zero) + CloseHandle(job); + if (process != IntPtr.Zero) + CloseHandle(process); + if (desktop != IntPtr.Zero) + CloseDesktop(desktop); + job = process = desktop = IntPtr.Zero; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public int Size; + public string Reserved; + public string Desktop; + public string Title; + public int X, Y, XSize, YSize, XCountChars, YCountChars, FillAttribute; + public uint Flags; + public short ShowWindow, Reserved2; + public IntPtr Reserved3, StdInput, StdOutput, StdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public IntPtr Process, Thread; + public int ProcessId, ThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobBasicLimits + { + public long PerProcessUserTimeLimit, PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass, SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobExtendedLimits + { + public JobBasicLimits Basic; + public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount; + public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount; + public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed; + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateDesktopW(string name, IntPtr device, IntPtr mode, uint flags, uint access, IntPtr attributes); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool CloseDesktop(IntPtr desktop); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObjectW(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref JobExtendedLimits info, int size); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcessW(string application, StringBuilder commandLine, IntPtr processAttributes, + IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, string currentDirectory, + ref StartupInfo startupInfo, out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr thread); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out int exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); +} +'@ + +function ConvertTo-CommandLineArgument([string]$Value) { + if ($Value -and $Value -notmatch '[\s"]') { + return $Value + } + + # CommandLineToArgvW: backslashes double only when a quote follows them. + $escaped = $Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1' + return "`"$escaped`"" +} + +# PowerShell splits an unbound -p:Name=Value into "-p:" and "Name=Value". +$hiddenTimeoutMinutes = 30 +$testArgs = [System.Collections.Generic.List[string]]::new() +for ($i = 0; $i -lt $args.Count; $i++) { + $arg = [string]$args[$i] + if ($arg -eq '-HiddenTimeoutMinutes' -and $i + 1 -lt $args.Count) { + $i++ + $hiddenTimeoutMinutes = [int]$args[$i] + continue + } + if ($arg -match '^-[^:]*:$' -and $i + 1 -lt $args.Count) { + $i++ + $arg += [string]$args[$i] + } + $testArgs.Add($arg) +} + +$repoRoot = Split-Path $PSScriptRoot -Parent +if ($testArgs.Count -eq 0) { + $testArgs.Add((Join-Path $repoRoot 'OpenNest.WinForms.Tests\OpenNest.WinForms.Tests.csproj')) +} + +$dotnet = (Get-Command dotnet -CommandType Application | Select-Object -First 1).Source +$commandLine = (@($dotnet, 'test') + $testArgs | ForEach-Object { ConvertTo-CommandLineArgument $_ }) -join ' ' +$logPath = Join-Path ([System.IO.Path]::GetTempPath()) "OpenNestTests-$([guid]::NewGuid().ToString('N')).log" + +# Build servers would be killed with the job while other builds may reuse them. +$buildEnvironment = @{ MSBUILDDISABLENODEREUSE = '1'; UseSharedCompilation = 'false' } +$savedEnvironment = @{} +foreach ($name in $buildEnvironment.Keys) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) + [Environment]::SetEnvironmentVariable($name, $buildEnvironment[$name]) +} + +$run = $null +$reader = $null +$exitCode = 124 +try { + $run = [HiddenDesktopRun]::new($commandLine, (Get-Location -PSProvider FileSystem).ProviderPath, $logPath) + $reader = [System.IO.StreamReader]::new( + [System.IO.FileStream]::new($logPath, 'Open', 'Read', 'ReadWrite, Delete'), + [System.Text.Encoding]::UTF8) + + $deadline = [DateTime]::UtcNow.AddMinutes($hiddenTimeoutMinutes) + $pending = '' + do { + $exited = $run.WaitForExit(200) + $pending += $reader.ReadToEnd() + $lines = $pending -split '\r?\n' + $pending = $lines[-1] + if ($lines.Count -gt 1) { + $lines[0..($lines.Count - 2)] | Write-Output + } + } until ($exited -or [DateTime]::UtcNow -ge $deadline) + + if ($pending) { + Write-Output $pending + } + if ($exited) { + $exitCode = $run.ExitCode + } + else { + Write-Error "Stopped after $hiddenTimeoutMinutes minutes. A modal dialog nobody can dismiss is the usual cause." -ErrorAction Continue + } +} +finally { + foreach ($name in $savedEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name]) + } + if ($reader) { $reader.Dispose() } + if ($run) { $run.Dispose() } + Remove-Item -LiteralPath $logPath -ErrorAction SilentlyContinue +} + +exit $exitCode