414 lines
22 KiB
PowerShell
414 lines
22 KiB
PowerShell
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
|
||
param(
|
||
[int]$Repeat = 1,
|
||
[ValidateSet('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'input-shadow')]
|
||
[string[]]$OnlyScenario = @()
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
$root = Split-Path -Parent $PSScriptRoot
|
||
$fixture = Join-Path $root 'build\x64\Debug\StellarX.UiFixture.exe'
|
||
$resultRoot = Join-Path $root 'TestResults\ui'
|
||
New-Item -ItemType Directory -Force -Path $resultRoot | Out-Null
|
||
|
||
Add-Type -AssemblyName System.Drawing
|
||
# EasyX 控件不是原生子窗口,因此使用 HWND、SendInput 和 PrintWindow 驱动与取证。
|
||
Add-Type @'
|
||
using System;
|
||
using System.ComponentModel;
|
||
using System.Runtime.InteropServices;
|
||
using System.Text;
|
||
public static class StellarXUiNative {
|
||
// 这些结构只覆盖 SendInput、客户区坐标转换和窗口枚举所需的 Win32 ABI。
|
||
private delegate bool EnumWindowsProc(IntPtr h, IntPtr parameter);
|
||
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }
|
||
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||
[StructLayout(LayoutKind.Sequential)] private struct MOUSEINPUT {
|
||
public int Dx; public int Dy; public uint MouseData; public uint Flags; public uint Time; public UIntPtr ExtraInfo;
|
||
}
|
||
[StructLayout(LayoutKind.Explicit)] private struct INPUTUNION {
|
||
[FieldOffset(0)] public MOUSEINPUT Mouse;
|
||
}
|
||
[StructLayout(LayoutKind.Sequential)] private struct INPUT {
|
||
public uint Type; public INPUTUNION Data;
|
||
}
|
||
[DllImport("user32.dll", SetLastError=true)] private static extern bool GetClientRect(IntPtr h, out RECT rect);
|
||
[DllImport("user32.dll", SetLastError=true)] private static extern bool ClientToScreen(IntPtr h, ref POINT point);
|
||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint msg, IntPtr w, IntPtr l);
|
||
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT point);
|
||
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr targetDc, uint flags);
|
||
[DllImport("user32.dll")] private static extern int GetSystemMetrics(int index);
|
||
[DllImport("user32.dll", SetLastError=true)] private static extern uint SendInput(uint count, INPUT[] inputs, int size);
|
||
[DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter);
|
||
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr h, out uint processId);
|
||
[DllImport("user32.dll", CharSet=CharSet.Unicode)] private static extern int GetWindowTextW(IntPtr h, StringBuilder text, int maxCount);
|
||
public static IntPtr FindProcessWindow(int processId, string title) {
|
||
// 同时匹配 PID 和完整标题,避免测试机上其他 EasyX 窗口被误绑定。
|
||
IntPtr result = IntPtr.Zero;
|
||
EnumWindows(delegate(IntPtr h, IntPtr parameter) {
|
||
uint ownerProcessId;
|
||
GetWindowThreadProcessId(h, out ownerProcessId);
|
||
if (ownerProcessId != (uint)processId) return true;
|
||
var text = new StringBuilder(512);
|
||
GetWindowTextW(h, text, text.Capacity);
|
||
if (!String.Equals(text.ToString(), title, StringComparison.Ordinal)) return true;
|
||
result = h;
|
||
return false;
|
||
}, IntPtr.Zero);
|
||
return result;
|
||
}
|
||
public static POINT GetClientPoint(IntPtr h, int x, int y) {
|
||
var point = new POINT { X = x, Y = y };
|
||
if (!ClientToScreen(h, ref point)) throw new Win32Exception();
|
||
return point;
|
||
}
|
||
public static RECT GetClientRectangle(IntPtr h) {
|
||
RECT rect;
|
||
if (!GetClientRect(h, out rect)) throw new Win32Exception();
|
||
return rect;
|
||
}
|
||
public static void ClickLeftMouse() {
|
||
// 用成对的按下/抬起事件模拟用户点击,事件会进入真实 EasyX 消息循环。
|
||
var inputs = new INPUT[2];
|
||
inputs[0].Type = 0;
|
||
inputs[0].Data.Mouse.Flags = 0x0002;
|
||
inputs[1].Type = 0;
|
||
inputs[1].Data.Mouse.Flags = 0x0004;
|
||
if (SendInput(2, inputs, Marshal.SizeOf(typeof(INPUT))) != 2) throw new Win32Exception();
|
||
}
|
||
public static void MoveMouseToScreen(int x, int y) {
|
||
// SendInput 的绝对坐标按虚拟屏幕换算,兼容多显示器和非零屏幕原点。
|
||
int left = GetSystemMetrics(76);
|
||
int top = GetSystemMetrics(77);
|
||
int width = GetSystemMetrics(78);
|
||
int height = GetSystemMetrics(79);
|
||
var input = new INPUT[1];
|
||
input[0].Type = 0;
|
||
input[0].Data.Mouse.Dx = (int)Math.Round((x - left) * 65535.0 / Math.Max(1, width - 1));
|
||
input[0].Data.Mouse.Dy = (int)Math.Round((y - top) * 65535.0 / Math.Max(1, height - 1));
|
||
input[0].Data.Mouse.Flags = 0xC001;
|
||
if (SendInput(1, input, Marshal.SizeOf(typeof(INPUT))) != 1) throw new Win32Exception();
|
||
}
|
||
}
|
||
'@
|
||
|
||
function Wait-Window([Diagnostics.Process]$process, [string]$title, [int]$timeoutMs = 15000) {
|
||
# fixture 启动后异步创建窗口;超时或提前退出都应报告明确原因。
|
||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||
$process.Refresh()
|
||
if ($process.HasExited) {
|
||
throw "Fixture exited before creating '$title' (exit code $($process.ExitCode))"
|
||
}
|
||
$h = [StellarXUiNative]::FindProcessWindow($process.Id, $title)
|
||
if ($h -ne [IntPtr]::Zero) { return $h }
|
||
Start-Sleep -Milliseconds 50
|
||
}
|
||
throw "Window not found: $title"
|
||
}
|
||
|
||
function Assert-NativeCloseExitsProcess([string]$fixturePath, [string]$runDir, [string]$scenario) {
|
||
# 模拟标题栏关闭命令。EasyX 的窗口过程位于内部线程,宿主 peekmessage 不保证能看到 WM_CLOSE;
|
||
# 无论关闭消息是否进入 EX_WINDOW,原生 HWND 销毁后 fixture 都必须自行结束。
|
||
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
|
||
# EasyX 在重定向标准输出的子进程中可能返回 0xC000013A;关闭回归不需要捕获输出,
|
||
# 因而保持与用户直接运行应用相同的控制台继承方式。
|
||
$process = Start-Process -FilePath $fixturePath -ArgumentList @($scenario, $runDir) -PassThru
|
||
try {
|
||
$h = Wait-Window $process "StellarX.UI.$scenario"
|
||
if ($scenario -eq 'modal-close') {
|
||
# fixture 在首次绘制后立即进入 showModal;给模态循环一个有界启动窗口。
|
||
Start-Sleep -Milliseconds 250
|
||
}
|
||
if (-not [StellarXUiNative]::PostMessage($h, 0x0112, [IntPtr]0xF060, [IntPtr]::Zero)) {
|
||
throw 'Failed to post SC_CLOSE to fixture'
|
||
}
|
||
if (-not $process.WaitForExit(5000)) {
|
||
$process.Kill()
|
||
$process.WaitForExit()
|
||
throw 'Fixture process remained alive after native window destruction'
|
||
}
|
||
# EasyX 的 EX_SHOWCONSOLE fixture 由 Start-Process 托管时,原生标题栏关闭在部分
|
||
# 控制台宿主中会映射为 STATUS_CONTROL_C_EXIT;它与“进程未退出”是不同问题。
|
||
if ($process.ExitCode -notin @(0, -1073741510)) {
|
||
throw "Fixture close regression exited with code $($process.ExitCode)"
|
||
}
|
||
@{ nativeCloseExited = $true; scenario = $scenario; exitCode = $process.ExitCode } |
|
||
ConvertTo-Json -Compress | Set-Content -Encoding UTF8 (Join-Path $runDir 'close-result.json')
|
||
}
|
||
finally {
|
||
if (-not $process.HasExited) {
|
||
$process.Kill()
|
||
$process.WaitForExit()
|
||
}
|
||
$process.Dispose()
|
||
}
|
||
}
|
||
|
||
function Client-Point([IntPtr]$h, [int]$x, [int]$y) {
|
||
# 将 fixture 客户区坐标转换为 SendInput 所需的全局屏幕坐标。
|
||
return [StellarXUiNative]::GetClientPoint($h, $x, $y)
|
||
}
|
||
|
||
function Move-Mouse([IntPtr]$h, [int]$x, [int]$y) {
|
||
# 输入坐标以客户区为单位,先转换到屏幕坐标再激活目标窗口。
|
||
$p = Client-Point $h $x $y
|
||
[void][StellarXUiNative]::SetForegroundWindow($h)
|
||
[StellarXUiNative]::MoveMouseToScreen($p.X, $p.Y)
|
||
Start-Sleep -Milliseconds 80
|
||
}
|
||
|
||
function Click-Mouse([IntPtr]$h, [int]$x, [int]$y) {
|
||
# 点击后等待一小段时间,让 EasyX 批量处理消息并完成统一重绘收口。
|
||
Move-Mouse $h $x $y
|
||
[StellarXUiNative]::ClickLeftMouse()
|
||
Start-Sleep -Milliseconds 250
|
||
}
|
||
|
||
function Post-KeyWave([IntPtr]$h, [int]$pairCount = 1000) {
|
||
# F24 is outside normal text entry and was already proven to cross both the
|
||
# WndProc and EasyX EX_KEY observation planes during stage 0B.
|
||
$keyDownLParam = [IntPtr]([long]0x00760001)
|
||
$keyUpLParam = [IntPtr]([long]0xC0760001)
|
||
for ($index = 0; $index -lt $pairCount; $index++) {
|
||
if (-not [StellarXUiNative]::PostMessage($h, 0x0100, [IntPtr]0x87, $keyDownLParam)) {
|
||
throw "Failed to post F24 key-down at pair $index"
|
||
}
|
||
if (-not [StellarXUiNative]::PostMessage($h, 0x0101, [IntPtr]0x87, $keyUpLParam)) {
|
||
throw "Failed to post F24 key-up at pair $index"
|
||
}
|
||
}
|
||
}
|
||
|
||
function Capture-Client([IntPtr]$h, [string]$path) {
|
||
# 只保存客户区,排除窗口边框;返回 Bitmap 供调用方继续读取关键像素。
|
||
$rect = [StellarXUiNative]::GetClientRectangle($h)
|
||
$bitmap = New-Object Drawing.Bitmap ($rect.Right - $rect.Left), ($rect.Bottom - $rect.Top)
|
||
$graphics = [Drawing.Graphics]::FromImage($bitmap)
|
||
$dc = $graphics.GetHdc()
|
||
try {
|
||
if (-not [StellarXUiNative]::PrintWindow($h, $dc, 1)) {
|
||
throw 'PrintWindow failed'
|
||
}
|
||
}
|
||
finally {
|
||
$graphics.ReleaseHdc($dc)
|
||
$graphics.Dispose()
|
||
}
|
||
$bitmap.Save($path, [Drawing.Imaging.ImageFormat]::Png)
|
||
return $bitmap
|
||
}
|
||
|
||
function Wait-Result([string]$path, [int]$timeoutMs = 15000) {
|
||
# 等待 fixture 写出 JSON 检查点,并把协议内容解析为 PowerShell 对象。
|
||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||
if (Test-Path $path) { return Get-Content -Raw $path | ConvertFrom-Json }
|
||
Start-Sleep -Milliseconds 50
|
||
}
|
||
throw "Result timeout: $path"
|
||
}
|
||
|
||
function Wait-TrueResultProperty([string]$path, [string]$property, [int]$timeoutMs = 15000) {
|
||
# 对话框等异步场景可能多次更新 JSON,直到目标状态真正变为 true 才继续。
|
||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||
if (Test-Path $path) {
|
||
try {
|
||
$result = Get-Content -Raw $path | ConvertFrom-Json
|
||
if ($result.$property -eq $true) { return $result }
|
||
}
|
||
catch {
|
||
# fixture 可能正处于 truncate 与 flush 之间,解析失败时继续轮询到场景超时。
|
||
}
|
||
}
|
||
Start-Sleep -Milliseconds 50
|
||
}
|
||
throw "Result property timeout: $path ($property)"
|
||
}
|
||
|
||
function Assert-Rgb($pixel, [int]$r, [int]$g, [int]$b, [string]$message) {
|
||
# 关键像素用精确颜色断言,避开文本抗锯齿导致的非确定性。
|
||
if ($pixel.R -ne $r -or $pixel.G -ne $g -or $pixel.B -ne $b) {
|
||
throw "$message actual=($($pixel.R),$($pixel.G),$($pixel.B)) expected=($r,$g,$b)"
|
||
}
|
||
}
|
||
|
||
function Assert-NotRgb($pixel, [int]$r, [int]$g, [int]$b, [string]$message) {
|
||
# 用于验证关闭或移出后,目标像素不再保留指定的控件颜色。
|
||
if ($pixel.R -eq $r -and $pixel.G -eq $g -and $pixel.B -eq $b) {
|
||
throw "$message actual=($($pixel.R),$($pixel.G),$($pixel.B))"
|
||
}
|
||
}
|
||
|
||
function Wait-ClientRgb([IntPtr]$h, [int]$x, [int]$y, [int]$r, [int]$g, [int]$b,
|
||
[string]$capturePath, [int]$timeoutMs = 15000) {
|
||
# 某些绘制在消息批次尾才完成,因此对目标像素进行有界轮询而不是固定猜测延时。
|
||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||
$actual = $null
|
||
while ($watch.ElapsedMilliseconds -lt $timeoutMs) {
|
||
$probe = Capture-Client $h $capturePath
|
||
$actual = $probe.GetPixel($x, $y)
|
||
$matched = $actual.R -eq $r -and $actual.G -eq $g -and $actual.B -eq $b
|
||
$probe.Dispose()
|
||
if ($matched) { return }
|
||
Start-Sleep -Milliseconds 50
|
||
}
|
||
throw "Pixel timeout at ($x,$y) actual=($($actual.R),$($actual.G),$($actual.B)) expected=($r,$g,$b)"
|
||
}
|
||
|
||
$savedCursor = New-Object StellarXUiNative+POINT
|
||
# UI 测试会抢占前台并移动鼠标,外层 finally 必须恢复用户原来的光标位置。
|
||
[void][StellarXUiNative]::GetCursorPos([ref]$savedCursor)
|
||
$allScenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog', 'input-shadow')
|
||
$scenarios = if ($OnlyScenario.Count -gt 0) { @($OnlyScenario) } else { $allScenarios }
|
||
$sessionRoot = Join-Path $resultRoot ((Get-Date -Format 'yyyyMMdd-HHmmss-fff') + "-$PID")
|
||
|
||
try {
|
||
# 场景按固定顺序串行执行,避免多个 EasyX 图形上下文同时争用桌面。
|
||
for ($iteration = 1; $iteration -le $Repeat; $iteration++) {
|
||
foreach ($scenario in $scenarios) {
|
||
$runDir = Join-Path $sessionRoot "run-$iteration-$scenario"
|
||
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
|
||
$resultFile = Join-Path $runDir "$scenario.json"
|
||
$stdoutFile = Join-Path $runDir 'fixture.stdout.log'
|
||
$stderrFile = Join-Path $runDir 'fixture.stderr.log'
|
||
$process = Start-Process -FilePath $fixture -ArgumentList @($scenario, $runDir) -PassThru `
|
||
-RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
|
||
$h = [IntPtr]::Zero
|
||
try {
|
||
# 先绑定唯一 HWND,再根据场景注入动作并等待 fixture 的 JSON 检查点。
|
||
$h = Wait-Window $process "StellarX.UI.$scenario"
|
||
[void][StellarXUiNative]::SetForegroundWindow($h)
|
||
switch ($scenario) {
|
||
'ownership' { Click-Mouse $h 80 40; $result = Wait-Result $resultFile; if (-not $result.removed) { throw 'ownership result failed' } }
|
||
'geometry' { Click-Mouse $h 80 40; $result = Wait-Result $resultFile; if ($result.x -ne 220 -or $result.textWidth -ne 80 -or $result.canvasWidth -ne 80 -or $result.labelVisible) { throw 'geometry result failed' }; Move-Mouse $h 460 300 }
|
||
'tooltip' { Move-Mouse $h 80 40; Wait-ClientRgb $h 200 61 255 255 255 (Join-Path $runDir 'tooltip-wait.png') }
|
||
'tab' { Click-Mouse $h 230 35; Click-Mouse $h 395 238; $result = Wait-Result $resultFile; if ($result.active -ne 1 -or $result.firstSelected -or -not $result.secondSelected -or $result.firstPageVisible -or -not $result.secondPageVisible) { throw 'tab uniqueness result failed' } }
|
||
'table' { Click-Mouse $h 395 268; $result = Wait-Result $resultFile; if ($result.width -le 40 -or $result.coverageRight -le $result.coverageLeft -or $result.coverageBottom -le $result.coverageTop) { throw 'table minimum width or coverage failed' } }
|
||
'dialog' {
|
||
Click-Mouse $h 90 40
|
||
Start-Sleep -Milliseconds 350
|
||
Click-Mouse $h 320 120
|
||
$result = Wait-TrueResultProperty $resultFile 'upperClosed'
|
||
if ($result.lowerClosed) { throw 'lower dialog closed with upper dialog' }
|
||
$lowerBitmap = Capture-Client $h (Join-Path $runDir 'lower-visible.png')
|
||
Assert-NotRgb ($lowerBitmap.GetPixel(150, 120)) 30 50 70 'lower dialog was not visible after upper close'
|
||
$lowerBitmap.Dispose()
|
||
Click-Mouse $h 240 185
|
||
$result = Wait-TrueResultProperty $resultFile 'lowerClosed'
|
||
}
|
||
'input-shadow' {
|
||
$expectedKeyEvents = 4000
|
||
# Keep every burst below the observed EasyX internal queue ceiling
|
||
# (63 usable entries in this environment).
|
||
# This measures sustained draining rather than overflow behavior of a
|
||
# third-party queue that the framework cannot resize.
|
||
foreach ($wave in 1..50) {
|
||
Post-KeyWave $h 20
|
||
Start-Sleep -Milliseconds 50
|
||
}
|
||
Start-Sleep -Milliseconds 250
|
||
$process.Refresh()
|
||
$memoryAfterWarmup = $process.PrivateMemorySize64
|
||
foreach ($wave in 1..50) {
|
||
Post-KeyWave $h 20
|
||
Start-Sleep -Milliseconds 50
|
||
}
|
||
Start-Sleep -Milliseconds 250
|
||
$process.Refresh()
|
||
$memoryAfterSecondWave = $process.PrivateMemorySize64
|
||
Click-Mouse $h 100 61
|
||
$result = Wait-Result $resultFile
|
||
if ($result.legacyKeyCount -ne $expectedKeyEvents) { throw "legacy EX_KEY count mismatch: $($result.legacyKeyCount)" }
|
||
if ($result.legacyKeyDownCount -ne ($expectedKeyEvents / 2) -or
|
||
$result.legacyKeyUpCount -ne ($expectedKeyEvents / 2)) {
|
||
throw "legacy EX_KEY action mismatch: down=$($result.legacyKeyDownCount) up=$($result.legacyKeyUpCount)"
|
||
}
|
||
if ($result.keyEvents -ne $expectedKeyEvents) { throw "normalized key count mismatch: $($result.keyEvents)" }
|
||
if ($result.pushed -ne $result.popped -or $result.currentDepth -ne 0) { throw 'native input queue did not drain completely' }
|
||
if (-not $result.sequenceStrict -or $result.faulted) { throw 'native input queue reported ordering or fail-stop failure' }
|
||
if ($result.rejectedTextSequences -ne 0 -or $result.imeQueryFailures -ne 0 -or
|
||
$result.discardedDecoderFragments -ne 0) {
|
||
throw 'key-only input shadow reported a text normalization anomaly'
|
||
}
|
||
$memoryGrowth = $memoryAfterSecondWave - $memoryAfterWarmup
|
||
if ($memoryGrowth -gt 16MB) { throw "input shadow memory grew after warmup: $memoryGrowth bytes" }
|
||
@{
|
||
expectedKeyEvents = $expectedKeyEvents
|
||
memoryAfterWarmup = $memoryAfterWarmup
|
||
memoryAfterSecondWave = $memoryAfterSecondWave
|
||
memoryGrowth = $memoryGrowth
|
||
} | ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $runDir 'input-shadow-memory.json')
|
||
}
|
||
}
|
||
$bitmap = Capture-Client $h (Join-Path $runDir 'screen.png')
|
||
if ($scenario -eq 'ownership' -or $scenario -eq 'geometry') {
|
||
Assert-Rgb ($bitmap.GetPixel(80, 40)) 30 50 70 "$scenario old region was not restored"
|
||
}
|
||
if ($scenario -eq 'geometry') {
|
||
Assert-Rgb ($bitmap.GetPixel(260, 40)) 80 160 220 'moved button was not rendered'
|
||
Assert-Rgb ($bitmap.GetPixel(25, 90)) 30 50 70 'hidden label left stale pixels'
|
||
Assert-Rgb ($bitmap.GetPixel(30, 130)) 30 50 70 'moved TextBox left stale pixels'
|
||
Assert-Rgb ($bitmap.GetPixel(230, 130)) 230 230 210 'moved TextBox was not rendered'
|
||
Assert-Rgb ($bitmap.GetPixel(30, 190)) 30 50 70 'moved Canvas left stale pixels'
|
||
Assert-Rgb ($bitmap.GetPixel(230, 190)) 40 120 90 'moved Canvas was not rendered'
|
||
}
|
||
if ($scenario -eq 'tooltip') {
|
||
Assert-Rgb ($bitmap.GetPixel(200, 61)) 255 255 255 'stationary tooltip did not appear'
|
||
Move-Mouse $h 460 300
|
||
$clearedBitmap = Capture-Client $h (Join-Path $runDir 'tooltip-cleared.png')
|
||
Assert-Rgb ($clearedBitmap.GetPixel(200, 61)) 30 50 70 'tooltip was not cleared after mouse exit'
|
||
$clearedBitmap.Dispose()
|
||
}
|
||
if ($scenario -eq 'tab') {
|
||
Assert-Rgb ($bitmap.GetPixel(50, 80)) 110 70 120 'active tab page pixels are incorrect'
|
||
}
|
||
if ($scenario -eq 'table') {
|
||
$outsideX = [Math]::Min(479, [int]$result.coverageRight + 5)
|
||
Assert-Rgb ($bitmap.GetPixel($outsideX, 30)) 30 50 70 'table drew outside reported horizontal coverage'
|
||
}
|
||
if ($scenario -eq 'dialog') {
|
||
Assert-Rgb ($bitmap.GetPixel(240, 150)) 30 50 70 'dialog pixels remained after both dialogs closed'
|
||
}
|
||
$bitmap.Dispose()
|
||
}
|
||
catch {
|
||
# 失败时保留驱动错误、fixture 输出和客户区截图,便于定位像素或状态问题。
|
||
@{ scenario = $scenario; message = $_.Exception.Message; processId = $process.Id; hwnd = $h.ToInt64() } |
|
||
ConvertTo-Json | Set-Content -Encoding UTF8 (Join-Path $runDir 'driver-error.json')
|
||
if ($h -ne [IntPtr]::Zero) {
|
||
try {
|
||
$failureBitmap = Capture-Client $h (Join-Path $runDir 'failure.png')
|
||
$failureBitmap.Dispose()
|
||
}
|
||
catch { }
|
||
}
|
||
throw
|
||
}
|
||
finally {
|
||
# 正常和异常路径都发送 WM_CLOSE;若 fixture 未退出,再按超时兜底终止其进程。
|
||
if (-not $process.HasExited) {
|
||
if ($h -ne [IntPtr]::Zero) {
|
||
[void][StellarXUiNative]::PostMessage($h, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
|
||
}
|
||
if (-not $process.WaitForExit(2000)) { $process.Kill() }
|
||
}
|
||
$process.Dispose()
|
||
}
|
||
}
|
||
|
||
Assert-NativeCloseExitsProcess $fixture `
|
||
(Join-Path $sessionRoot "run-$iteration-native-close") 'native-close'
|
||
Assert-NativeCloseExitsProcess $fixture `
|
||
(Join-Path $sessionRoot "run-$iteration-modal-native-close") 'modal-close'
|
||
}
|
||
}
|
||
finally {
|
||
[void][StellarXUiNative]::SetCursorPos($savedCursor.X, $savedCursor.Y)
|
||
}
|
||
|
||
Write-Host "UI tests passed: $Repeat iteration(s)"
|