mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-01 02:21:59 -05:00
WinForms tests open real windows that take focus from the user's work. Test-Hidden.ps1 runs them on a separate Win32 desktop in the same window station, so forms keep real handles and message loops while nothing appears. A job object ends the whole tree on any exit, and a timeout stops runs stuck on a modal dialog nobody can dismiss there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
11 KiB
PowerShell
294 lines
11 KiB
PowerShell
<#
|
|
.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
|