Files
StellarX-kaifa/scripts/run-ui-tests.ps1
T
Codex 225bc35219 fix(test): 修复 Windows PowerShell 5 UI 脚本解析
为 run-ui-tests.ps1 补充 UTF-8 BOM,新增 BUG/Fix 与 TESTING 记录。

Windows PowerShell 5.1 完整 UI 回归 1 轮通过;按用户确认同时纳入 /demo/ 忽略规则。
2026-10-05 01:27:50 +08:00

351 lines
18 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 外部 UI 驱动:启动确定性 fixture,注入真实鼠标输入,并保存像素与状态诊断。
param([int]$Repeat = 1)
$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 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)
$scenarios = @('ownership', 'geometry', 'tooltip', 'tab', 'table', 'dialog')
$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'
}
}
$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)"