waybetter/dev-tools/close-check.ps1
CRT 5c64f2f6f8 dev-tools: the generic development harnesses (perf probes, close check, tab and webview stress)
Moved from wayBetter for X; an app passes its own origin, process name, scripts and fixtures.
2026-09-30 14:48:06 +07:00

137 lines
7.1 KiB
PowerShell

# Graceful-close regression check (Windows): the app must exit on a plain `taskkill /PID` (WM_CLOSE,
# no /F) in every window state, and on a simulated sign-out (WM_QUERYENDSESSION + WM_ENDSESSION).
#
# Why: tao's thread event target and the single-instance plugin's message window are visible 1 px
# unowned top-level windows. taskkill closes only the first such window in z-order; a minimized
# window sinks below them, so before the fix the close destroyed a helper and the app stayed alive.
#
# powershell -ExecutionPolicy Bypass -File dev-tools/close-check.ps1 -Exe <the app's release exe> [-Cases normal,minimized,...]
#
# Cases: normal, minimized, maximized,
# unfocused (another window in front), debugport (WebView2 remote debugging on), endsession
# (minimized, then the sign-out messages). Every case restarts the app with the owner's saved
# layout (tabs, deck columns); "webviews" counts the shell plus every tab/column webview. Any
# running copy of the same exe is closed first; a copy that does not exit is only force-killed when
# its path is this exe. Relaunch the app normally afterwards.
param(
[Parameter(Mandatory = $true)][string]$Exe,
[string[]]$Cases = @('normal', 'minimized', 'maximized', 'unfocused', 'debugport', 'endsession'),
[int]$StartupSeconds = 10,
[int]$ExitSeconds = 10
)
$ErrorActionPreference = 'Stop'
$Exe = (Resolve-Path $Exe).Path
$ProcessName = [IO.Path]::GetFileNameWithoutExtension($Exe)
Add-Type @"
using System; using System.Text; using System.Runtime.InteropServices; using System.Collections.Generic;
public static class CloseCheckWin {
public delegate bool EnumProc(IntPtr h, IntPtr l);
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc p, IntPtr l);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr SendMessageTimeout(IntPtr h, uint m, IntPtr w, IntPtr l, uint flags, uint ms, out IntPtr r);
public static List<IntPtr> Windows(uint pid) {
var r = new List<IntPtr>();
EnumWindows((h, l) => { uint p; GetWindowThreadProcessId(h, out p); if (p == pid) r.Add(h); return true; }, IntPtr.Zero);
return r;
}
[DllImport("user32.dll")] static extern bool EnumChildWindows(IntPtr h, EnumProc p, IntPtr l);
public static int CountChildren(IntPtr parent, string cls) {
int n = 0;
EnumChildWindows(parent, (h, l) => { if (ClassOf(h) == cls) n++; return true; }, IntPtr.Zero);
return n;
}
public static string ClassOf(IntPtr h) { var c = new StringBuilder(256); GetClassName(h, c, 256); return c.ToString(); }
}
"@
function Get-Ours { Get-Process $ProcessName -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $Exe } }
function Describe-Windows([int]$ProcessId) {
([CloseCheckWin]::Windows([uint32]$ProcessId) | Where-Object { [CloseCheckWin]::IsWindowVisible($_) } | ForEach-Object {
$class = [CloseCheckWin]::ClassOf($_)
if ([CloseCheckWin]::IsIconic($_)) { "$class(min)" } else { $class }
}) -join ' > '
}
function Stop-Ours {
foreach ($p in @(Get-Ours)) {
& taskkill.exe /PID $p.Id 2>&1 | Out-Null
if (-not $p.WaitForExit($ExitSeconds * 1000)) {
Write-Host " (force-killing leftover $($p.Id) at $($p.Path))"
Stop-Process -Id $p.Id -Force
$p.WaitForExit(5000) | Out-Null
}
}
}
function Start-Ours([hashtable]$Vars = @{}) {
$saved = @{}
foreach ($key in $Vars.Keys) { $saved[$key] = [Environment]::GetEnvironmentVariable($key); [Environment]::SetEnvironmentVariable($key, $Vars[$key]) }
try { Start-Process $Exe -WindowStyle Normal } finally {
foreach ($key in $saved.Keys) { [Environment]::SetEnvironmentVariable($key, $saved[$key]) }
}
$deadline = (Get-Date).AddSeconds(30)
do {
Start-Sleep -Milliseconds 500
$p = Get-Ours | Select-Object -First 1
$main = if ($p) { [CloseCheckWin]::Windows([uint32]$p.Id) | Where-Object { [CloseCheckWin]::ClassOf($_) -eq 'Tauri Window' -and [CloseCheckWin]::IsWindowVisible($_) } | Select-Object -First 1 }
} until ($main -or (Get-Date) -gt $deadline)
if (-not $main) { throw "the app window did not appear" }
Start-Sleep -Seconds $StartupSeconds # tabs, deck columns and plugins load
return @{ Process = $p; Main = $main }
}
function Wait-Exit($p) {
$watch = [Diagnostics.Stopwatch]::StartNew()
$exited = $p.WaitForExit($ExitSeconds * 1000)
return @{ Exited = $exited; Seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1) }
}
$results = @()
Stop-Ours
foreach ($case in $Cases) {
$extra = if ($case -eq 'debugport') { @{ WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--remote-debugging-port=9333' } } else { @{} }
$app = Start-Ours $extra
$p = $app.Process; $main = $app.Main
switch ($case) {
'minimized' { [CloseCheckWin]::ShowWindow($main, 6) | Out-Null }
'endsession' { [CloseCheckWin]::ShowWindow($main, 6) | Out-Null }
'maximized' { [CloseCheckWin]::ShowWindow($main, 3) | Out-Null }
'unfocused' {
[CloseCheckWin]::ShowWindow($main, 9) | Out-Null
$shell = Get-Process explorer -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1
if ($shell) { [CloseCheckWin]::SetForegroundWindow($shell.MainWindowHandle) | Out-Null }
}
}
Start-Sleep -Seconds 2
$order = Describe-Windows $p.Id
$webviews = [CloseCheckWin]::CountChildren($main, 'WRY_WEBVIEW')
if ($case -eq 'endsession') {
# What Windows sends every top-level window at sign-out (ENDSESSION_LOGOFF).
$r = [IntPtr]::Zero
foreach ($h in [CloseCheckWin]::Windows([uint32]$p.Id)) { [CloseCheckWin]::SendMessageTimeout($h, 0x11, [IntPtr]::Zero, [IntPtr]0x80000000, 2, 2000, [ref]$r) | Out-Null }
foreach ($h in [CloseCheckWin]::Windows([uint32]$p.Id)) { [CloseCheckWin]::SendMessageTimeout($h, 0x16, [IntPtr]1, [IntPtr]0x80000000, 2, 2000, [ref]$r) | Out-Null }
} else {
& taskkill.exe /PID $p.Id 2>&1 | Out-Null
}
$outcome = Wait-Exit $p
$leftover = if ($outcome.Exited) { '' } else { Describe-Windows $p.Id }
$pass = $outcome.Exited
$results += [pscustomobject]@{ Case = $case; Pass = $pass; Seconds = $outcome.Seconds; Webviews = $webviews; WindowsBefore = $order; Leftover = $leftover }
Write-Host ('{0} {1,-16} exited={2} in {3}s webviews={5} [{4}]' -f ($(if ($pass) { 'ok ' } else { 'FAIL' })), $case, $outcome.Exited, $outcome.Seconds, $order, $webviews)
if (-not $pass) { Write-Host " still open: $leftover"; Stop-Ours }
}
$failed = @($results | Where-Object { -not $_.Pass })
Write-Host ''
Write-Host ("{0}/{1} close cases passed" -f ($results.Count - $failed.Count), $results.Count)
if ($failed.Count) { exit 1 }
exit 0