From 1e8e894dbaecfc9baf2ecb6feecc67811173c301 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 5 Sep 2026 22:49:18 +0800 Subject: [PATCH] fix: restore existing WeChat instance and add M6 validation tools --- docs/validation/M6-Stability-2026-09-05.md | 58 ++++++ .../evidence/M6-20260905/listener.json | 7 + .../evidence/M6-20260905/recovery.json | 18 ++ .../evidence/M6-20260905/stability-30s.json | 121 +++++++++++ .../M6-20260905/validation-summary.json | 56 +++++ scripts/windows/Invoke-Endurance.ps1 | 37 ++++ scripts/windows/Invoke-M6Validation.ps1 | 39 ++++ src/WxAgent.Core/ChatMessages.cs | 12 +- src/WxAgent.Core/UiSnapshotDiagnostics.cs | 44 ++++ src/WxAgent.Core/WechatLocators.cs | 10 + src/WxAgent.Core/WechatStability.cs | 85 ++++++++ src/WxAgent.Host/Program.cs | 137 +++++++++++++ src/WxAgent.Windows/NativeMethods.txt | 11 + src/WxAgent.Windows/WechatChatClient.cs | 46 +++-- src/WxAgent.Windows/WechatDoctor.cs | 67 +++++- src/WxAgent.Windows/WechatListenerSmoke.cs | 58 ++++++ src/WxAgent.Windows/WechatNativeWindow.cs | 52 +++++ src/WxAgent.Windows/WechatRecoverySmoke.cs | 45 ++++ src/WxAgent.Windows/WechatStabilityRunner.cs | 192 ++++++++++++++++++ src/WxAgent.Windows/WechatTray.cs | 109 ++++++++++ src/WxAgent.Windows/WechatUiInspector.cs | 37 ++-- .../WxAgent.Core.Tests/UiDiagnosticsTests.cs | 47 +++++ .../WechatStabilityTests.cs | 117 +++++++++++ 23 files changed, 1357 insertions(+), 48 deletions(-) create mode 100644 docs/validation/M6-Stability-2026-09-05.md create mode 100644 docs/validation/evidence/M6-20260905/listener.json create mode 100644 docs/validation/evidence/M6-20260905/recovery.json create mode 100644 docs/validation/evidence/M6-20260905/stability-30s.json create mode 100644 docs/validation/evidence/M6-20260905/validation-summary.json create mode 100644 scripts/windows/Invoke-Endurance.ps1 create mode 100644 scripts/windows/Invoke-M6Validation.ps1 create mode 100644 src/WxAgent.Core/UiSnapshotDiagnostics.cs create mode 100644 src/WxAgent.Core/WechatStability.cs create mode 100644 src/WxAgent.Windows/WechatListenerSmoke.cs create mode 100644 src/WxAgent.Windows/WechatNativeWindow.cs create mode 100644 src/WxAgent.Windows/WechatRecoverySmoke.cs create mode 100644 src/WxAgent.Windows/WechatStabilityRunner.cs create mode 100644 src/WxAgent.Windows/WechatTray.cs create mode 100644 tests/WxAgent.Core.Tests/UiDiagnosticsTests.cs create mode 100644 tests/WxAgent.Core.Tests/WechatStabilityTests.cs diff --git a/docs/validation/M6-Stability-2026-09-05.md b/docs/validation/M6-Stability-2026-09-05.md new file mode 100644 index 0000000..4fb8100 --- /dev/null +++ b/docs/validation/M6-Stability-2026-09-05.md @@ -0,0 +1,58 @@ +# M6 稳定性与兼容性——进行中 + +**M6 尚未完成。** 短时测试不能替代开发计划 §11.3 的 8/24/72 小时测试,也不能替代 Windows 11 和不同 DPI 的真机验收。每项未验证内容继续保留,不按“接口存在/编译通过”结项。 + +## 已实现并验证 + +- `recover-ui`:恢复原有微信实例,不启动 `Weixin.exe`、不模拟登录。 + - 优先使用 FlaUI UIA3 的通知区域按钮。 + - Windows 10 通知区域未通过 UIA3 暴露图标时,使用公开 MSAA 接口定位,再按真实图标边界双击。不读写 Explorer 内存、不猜测托盘回调消息。 + - 不再使用 `ShowWindow` 显示灰色 Qt 外壳;不对主窗口盲按 Esc(这会再次隐藏到托盘)。 + - 最小化、正常关闭到托盘两种状态均验证了:状态实际发生、恢复同一 HWND 和 PID、`MainView/session_list/chat_message_list` 存在。 +- 共享窗口绑定采用以上恢复路径,供普通业务调用与监听复用。 +- 监听在统一命令队列内校验目标会话,再获取快照;避免将其他会话内容标成文件传输助手消息。 +- `listener-smoke`:仅向文件传输助手发送一条唯一测试消息;收到一次事件,加载 checkpoint 重启监听后不重复上报。 +- UI 树遍历有深度、节点数和 runtime ID 去重保护。截断/重复节点会明确失败,保存部分快照而非栈溢出或假报完整。 +- `diagnose --output --baseline `:脱敏快照、操作系统/微信版本、会话、DPI/窗口边界、进程资源、必需控件及基线差异、关联 ID。 +- `endurance`:JSONL 持续落盘;统计微信及 Agent 的内存/句柄/线程、消息事件、重连事件、最近有效快照。记录请求/实测时长;未跑满、监听提前退出、快照停滞、最终错误均不算健康。输出目录禁止覆盖。 + +## 当前已取得的证据 + +环境:`DESKTOP-EGI7QCK`,Windows 10 build 19044,微信 4.1.13.63,Session 1,窗口 DPI 96(100%)。 + +- Linux Core:49/49 通过;Release 全量构建 0 警告、0 错误;Windows self-contained 发布通过。 +- Windows 8 个命令均 exit 0:recover、doctor、inspect、smoke、recovery-smoke、listener-smoke、30 秒 stability-smoke、diagnose。 +- listener:MatchingEvents=1、EventsAfterRestart=0、Snapshots=5、CheckpointSaved=true。 +- 基线对比:Added/Removed/MissingRequiredControls 均为空。 +- 30 秒短测:ObservedSeconds=30.0794228、CompletedDuration=true、7 个样本、有效监听快照持续更新、Findings 为空。 +- 这仅证明上述 **30 秒** 样本,不证明长时间无泄漏。 + +本次发布包 SHA-256:`5899FD7A07E85B9C20679E06012CB0ED494F96916A8C58F81ACFC533286CA009`。 + +脱敏摘要见 `evidence/M6-20260905/`;完整本地证据位于 `artifacts/m6-final-validation/m6-validation-20260905T143832/`。历史失败证据不作为通过依据。 + +## 尚待完成的验收 + +- [ ] 8/24/72 小时真实监听及长时资源趋势。 +- [ ] 网络中断/恢复、20 次微信重启后的恢复(不能绕过登录或锁屏)。 +- [ ] 常见弹窗、会话切换的专门恢复回归。 +- [ ] 多会话/多子窗口消息归属与去重、长列表滚动场景。 +- [ ] 重新核对 M2 连续 500 条发送无重复与 M3 8 小时监听证据;未找到对应证据前仍未验收。 +- [ ] Windows 10 多 DPI 与 Windows 11 兼容矩阵。目前仅证明 Windows 10 / 96 DPI。 +- [ ] 汇总此前阶段尚未真机覆盖的回归项;M4/M5 中仅编译/门禁验证的写操作不冒充端到端验收。 +- [ ] 全部要求通过后更新阶段结论,并提交、PUSH 阶段验收记录。 + +用户明确跳过无麦克风环境下的 SendAudio;不扩展该豁免到其他功能。 + +## 重复执行 + +在已登录且未锁定、微信所在的用户会话执行;不要在 Session 0 运行。 + +```powershell +.\Invoke-M6Validation.ps1 -Executable .\WxAgent.Host.exe -OutputDirectory C:\Users\Rogee\wx-agent\validation-new +.\Invoke-Endurance.ps1 -Executable .\WxAgent.Host.exe -OutputDirectory C:\Users\Rogee\wx-agent\endurance-new -Hours 72 -SampleSeconds 60 +``` + +脚本位于 `scripts/windows/`。每次使用新输出目录并保留 BinarySha256、请求/实测时长、退出码及全部样本。72 小时运行会在经过 8/24 小时时保存阶段快照,最终 `metrics/report.json` 才是最终结果。 + +所有早期调试用 `WxAgent*` 一次性定时任务已禁用,防止它们在 23:59 自动重发测试消息;后续任务仅手动触发,不配置未来重复触发器。 diff --git a/docs/validation/evidence/M6-20260905/listener.json b/docs/validation/evidence/M6-20260905/listener.json new file mode 100644 index 0000000..6596516 --- /dev/null +++ b/docs/validation/evidence/M6-20260905/listener.json @@ -0,0 +1,7 @@ +{ + "Sent": true, + "MatchingEvents": 1, + "EventsAfterRestart": 0, + "Snapshots": 5, + "CheckpointSaved": true +} \ No newline at end of file diff --git a/docs/validation/evidence/M6-20260905/recovery.json b/docs/validation/evidence/M6-20260905/recovery.json new file mode 100644 index 0000000..f2edb17 --- /dev/null +++ b/docs/validation/evidence/M6-20260905/recovery.json @@ -0,0 +1,18 @@ +[ + { + "State": "minimized", + "TransitionObserved": true, + "SameInstance": true, + "MainViewFound": true, + "SessionsFound": true, + "MessagesFound": true + }, + { + "State": "tray-hidden", + "TransitionObserved": true, + "SameInstance": true, + "MainViewFound": true, + "SessionsFound": true, + "MessagesFound": true + } +] \ No newline at end of file diff --git a/docs/validation/evidence/M6-20260905/stability-30s.json b/docs/validation/evidence/M6-20260905/stability-30s.json new file mode 100644 index 0000000..21d0ec1 --- /dev/null +++ b/docs/validation/evidence/M6-20260905/stability-30s.json @@ -0,0 +1,121 @@ +{ + "StartedAt": "2026-09-05T14:39:32.0610704+00:00", + "FinishedAt": "2026-09-05T14:40:02.1378009+00:00", + "Samples": [ + { + "Timestamp": "2026-09-05T14:39:32.2343907+00:00", + "ProcessCount": 5, + "WorkingSetBytes": 153997312, + "HandleCount": 3535, + "ThreadCount": 183, + "WindowFound": true, + "Errors": [], + "MessageEvents": 0, + "ReconnectEvents": 0, + "AgentWorkingSetBytes": 43053056, + "AgentHandleCount": 389, + "AgentThreadCount": 20, + "LastSnapshotAt": null + }, + { + "Timestamp": "2026-09-05T14:39:37.4016222+00:00", + "ProcessCount": 5, + "WorkingSetBytes": 153575424, + "HandleCount": 3535, + "ThreadCount": 183, + "WindowFound": true, + "Errors": [], + "MessageEvents": 0, + "ReconnectEvents": 0, + "AgentWorkingSetBytes": 51077120, + "AgentHandleCount": 437, + "AgentThreadCount": 25, + "LastSnapshotAt": "2026-09-05T14:39:36.5158013+00:00" + }, + { + "Timestamp": "2026-09-05T14:39:42.4826756+00:00", + "ProcessCount": 5, + "WorkingSetBytes": 153645056, + "HandleCount": 3535, + "ThreadCount": 183, + "WindowFound": true, + "Errors": [], + "MessageEvents": 0, + "ReconnectEvents": 0, + "AgentWorkingSetBytes": 49594368, + "AgentHandleCount": 439, + "AgentThreadCount": 25, + "LastSnapshotAt": "2026-09-05T14:39:40.6950674+00:00" + }, + { + "Timestamp": "2026-09-05T14:39:47.5679204+00:00", + "ProcessCount": 5, + "WorkingSetBytes": 153632768, + "HandleCount": 3535, + "ThreadCount": 184, + "WindowFound": true, + "Errors": [], + "MessageEvents": 0, + "ReconnectEvents": 0, + "AgentWorkingSetBytes": 50147328, + "AgentHandleCount": 439, + "AgentThreadCount": 25, + "LastSnapshotAt": "2026-09-05T14:39:46.9856634+00:00" + }, + { + "Timestamp": "2026-09-05T14:39:52.6503147+00:00", + "ProcessCount": 5, + "WorkingSetBytes": 153395200, + "HandleCount": 3537, + "ThreadCount": 184, + "WindowFound": true, + "Errors": [], + "MessageEvents": 0, + "ReconnectEvents": 0, + "AgentWorkingSetBytes": 51089408, + "AgentHandleCount": 439, + "AgentThreadCount": 23, + "LastSnapshotAt": "2026-09-05T14:39:51.1846333+00:00" + }, + { + "Timestamp": "2026-09-05T14:39:57.7230086+00:00", + "ProcessCount": 5, + "WorkingSetBytes": 152985600, + "HandleCount": 3537, + "ThreadCount": 184, + "WindowFound": true, + "Errors": [], + "MessageEvents": 0, + "ReconnectEvents": 0, + "AgentWorkingSetBytes": 51859456, + "AgentHandleCount": 439, + "AgentThreadCount": 23, + "LastSnapshotAt": "2026-09-05T14:39:57.4318646+00:00" + }, + { + "Timestamp": "2026-09-05T14:40:02.1355012+00:00", + "ProcessCount": 5, + "WorkingSetBytes": 152502272, + "HandleCount": 3537, + "ThreadCount": 184, + "WindowFound": true, + "Errors": [], + "MessageEvents": 0, + "ReconnectEvents": 0, + "AgentWorkingSetBytes": 52297728, + "AgentHandleCount": 442, + "AgentThreadCount": 23, + "LastSnapshotAt": "2026-09-05T14:40:01.6026352+00:00" + } + ], + "MessageEvents": 0, + "ReconnectEvents": 0, + "WorkingSetGrowthBytes": -1495040, + "HandleGrowth": 2, + "ThreadGrowth": 1, + "Healthy": true, + "Findings": [], + "RequestedSeconds": 30, + "ObservedSeconds": 30.0794228, + "CompletedDuration": true +} \ No newline at end of file diff --git a/docs/validation/evidence/M6-20260905/validation-summary.json b/docs/validation/evidence/M6-20260905/validation-summary.json new file mode 100644 index 0000000..4960118 --- /dev/null +++ b/docs/validation/evidence/M6-20260905/validation-summary.json @@ -0,0 +1,56 @@ +{ + "BinarySha256": "5899FD7A07E85B9C20679E06012CB0ED494F96916A8C58F81ACFC533286CA009", + "Passed": true, + "OperatingSystem": "Microsoft Windows NT 10.0.19044.0", + "Results": [ + { + "ExitCode": 0, + "Name": "recover", + "StartedAt": "\/Date(1788619114443)\/", + "FinishedAt": "\/Date(1788619157247)\/" + }, + { + "ExitCode": 0, + "Name": "doctor", + "StartedAt": "\/Date(1788619157248)\/", + "FinishedAt": "\/Date(1788619157696)\/" + }, + { + "ExitCode": 0, + "Name": "inspect", + "StartedAt": "\/Date(1788619157696)\/", + "FinishedAt": "\/Date(1788619158434)\/" + }, + { + "ExitCode": 0, + "Name": "smoke", + "StartedAt": "\/Date(1788619158434)\/", + "FinishedAt": "\/Date(1788619160226)\/" + }, + { + "ExitCode": 0, + "Name": "recovery", + "StartedAt": "\/Date(1788619160226)\/", + "FinishedAt": "\/Date(1788619162163)\/" + }, + { + "ExitCode": 0, + "Name": "listener", + "StartedAt": "\/Date(1788619162163)\/", + "FinishedAt": "\/Date(1788619171785)\/" + }, + { + "ExitCode": 0, + "Name": "stability", + "StartedAt": "\/Date(1788619171785)\/", + "FinishedAt": "\/Date(1788619202188)\/" + }, + { + "ExitCode": 0, + "Name": "diagnose", + "StartedAt": "\/Date(1788619202188)\/", + "FinishedAt": "\/Date(1788619203080)\/" + } + ], + "SessionId": 1 +} \ No newline at end of file diff --git a/scripts/windows/Invoke-Endurance.ps1 b/scripts/windows/Invoke-Endurance.ps1 new file mode 100644 index 0000000..216552a --- /dev/null +++ b/scripts/windows/Invoke-Endurance.ps1 @@ -0,0 +1,37 @@ +param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][string]$OutputDirectory, + [ValidateSet(8,24,72)][int]$Hours = 72, + [ValidateRange(1,3600)][int]$SampleSeconds = 60 +) +$ErrorActionPreference = 'Stop' +$exe = (Resolve-Path $Executable).Path +$directory = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path $directory) { throw 'Use a new output directory; endurance evidence is never overwritten.' } +[IO.Directory]::CreateDirectory($directory) | Out-Null +$utf8 = New-Object Text.UTF8Encoding($false) +$started = [DateTimeOffset]::UtcNow +$info = @{ + StartedAt=$started.ToString('o') + ExpectedFinish=$started.AddHours($Hours).ToString('o') + Hours=$Hours + SampleSeconds=$SampleSeconds + Executable=$exe + BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash + SessionId=[Diagnostics.Process]::GetCurrentProcess().SessionId + OperatingSystem=[Environment]::OSVersion.VersionString + WritesMessages=$false +} +[IO.File]::WriteAllText("$directory\run-info.json", ($info | ConvertTo-Json), $utf8) +$code = 1 +try { + & $exe endurance --hours $Hours --sample-seconds $SampleSeconds --output "$directory\metrics" | + ForEach-Object { [IO.File]::AppendAllText("$directory\console.json", $_ + [Environment]::NewLine, $utf8) } + $code = $LASTEXITCODE +} catch { + [IO.File]::WriteAllText("$directory\launcher-error.txt", $_.Exception.GetType().FullName, $utf8) +} finally { + [IO.File]::WriteAllText("$directory\exit-code.txt", [string]$code, $utf8) + [IO.File]::WriteAllText("$directory\finished-at.txt", [DateTimeOffset]::UtcNow.ToString('o'), $utf8) +} +exit $code diff --git a/scripts/windows/Invoke-M6Validation.ps1 b/scripts/windows/Invoke-M6Validation.ps1 new file mode 100644 index 0000000..516937a --- /dev/null +++ b/scripts/windows/Invoke-M6Validation.ps1 @@ -0,0 +1,39 @@ +param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][string]$OutputDirectory +) +$ErrorActionPreference = 'Stop' +$exe = (Resolve-Path $Executable).Path +$directory = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path $directory) { throw 'Use a new output directory to preserve earlier evidence.' } +[IO.Directory]::CreateDirectory($directory) | Out-Null +$utf8 = New-Object Text.UTF8Encoding($false) +$results = @() +$cases = @( + @{ Name='recover'; Args=@('recover-ui','--timeout','30') }, + @{ Name='doctor'; Args=@('doctor','--timeout','30') }, + @{ Name='inspect'; Args=@('inspect-ui','--output',"$directory\ui-tree.json",'--timeout','30') }, + @{ Name='smoke'; Args=@('smoke','--output',"$directory\smoke-ui-tree.json",'--timeout','30') }, + @{ Name='recovery'; Args=@('recovery-smoke','--timeout','60') }, + @{ Name='listener'; Args=@('listener-smoke','--output',"$directory\listener",'--timeout','60') }, + @{ Name='stability'; Args=@('stability-smoke','--seconds','30','--sample-seconds','5','--output',"$directory\stability",'--timeout','90') }, + @{ Name='diagnose'; Args=@('diagnose','--baseline',"$directory\ui-tree.json",'--output',"$directory\diagnostics",'--timeout','60') } +) +foreach ($case in $cases) { + $started = [DateTimeOffset]::UtcNow + $arguments = $case.Args + $lines = & $exe @arguments + $code = $LASTEXITCODE + [IO.File]::WriteAllText("$directory\$($case.Name).json", ($lines -join [Environment]::NewLine), $utf8) + $results += @{ Name=$case.Name; ExitCode=$code; StartedAt=$started; FinishedAt=[DateTimeOffset]::UtcNow } +} +$summary = @{ + BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash + SessionId=[Diagnostics.Process]::GetCurrentProcess().SessionId + OperatingSystem=[Environment]::OSVersion.VersionString + Results=$results + Passed=(@($results | Where-Object { $_.ExitCode -ne 0 }).Count -eq 0) +} +[IO.File]::WriteAllText("$directory\validation-summary.json", ($summary | ConvertTo-Json -Depth 8), $utf8) +if (!$summary.Passed) { exit 2 } +exit 0 diff --git a/src/WxAgent.Core/ChatMessages.cs b/src/WxAgent.Core/ChatMessages.cs index 7b6ff28..5a72c10 100644 --- a/src/WxAgent.Core/ChatMessages.cs +++ b/src/WxAgent.Core/ChatMessages.cs @@ -130,11 +130,19 @@ public static class VisibleMessageParser } } -public sealed class BoundedMessageDeduper(int capacity = 2048) +public sealed class BoundedMessageDeduper { + private readonly int _capacity; private readonly Queue _order = new(); private readonly HashSet _seen = new(StringComparer.Ordinal); + public BoundedMessageDeduper(int capacity = 2048) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be positive."); + _capacity = capacity; + } + public bool TryAdd(string fingerprint) { ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint); @@ -144,7 +152,7 @@ public sealed class BoundedMessageDeduper(int capacity = 2048) } _order.Enqueue(fingerprint); - if (_order.Count > capacity) + if (_order.Count > _capacity) { _seen.Remove(_order.Dequeue()); } diff --git a/src/WxAgent.Core/UiSnapshotDiagnostics.cs b/src/WxAgent.Core/UiSnapshotDiagnostics.cs new file mode 100644 index 0000000..6915e40 --- /dev/null +++ b/src/WxAgent.Core/UiSnapshotDiagnostics.cs @@ -0,0 +1,44 @@ +namespace WxAgent.Core; + +public sealed class UiTraversalGuard(int maxNodes = 10_000, int maxDepth = 40) +{ + private readonly HashSet _visited = new(StringComparer.Ordinal); + public int NodeCount { get; private set; } + public bool Truncated { get; private set; } + public bool CycleDetected { get; private set; } + + public bool TryVisit(string runtimeId, int depth) + { + if (NodeCount >= maxNodes || depth > maxDepth) + { + Truncated = true; + return false; + } + if (runtimeId.Length > 0 && !_visited.Add(runtimeId)) + { + CycleDetected = true; + return false; + } + NodeCount++; + return true; + } +} + +public sealed record UiSnapshotComparison(IReadOnlyList AddedAutomationIds, + IReadOnlyList RemovedAutomationIds, IReadOnlyList MissingRequiredControls) +{ + public static UiSnapshotComparison Compare(UiNodeSnapshot baseline, UiNodeSnapshot current) + { + static IEnumerable ReadIds(UiNodeSnapshot root) => + new[] { root.AutomationId }.Concat(root.Children.SelectMany(ReadIds)); + static HashSet Ids(UiNodeSnapshot root) => ReadIds(root) + .Where(id => id.Length > 0 && !id.StartsWith("[id:", StringComparison.Ordinal)) + .ToHashSet(StringComparer.Ordinal); + var before = Ids(baseline); + var after = Ids(current); + return new UiSnapshotComparison( + after.Except(before).Order(StringComparer.Ordinal).ToArray(), + before.Except(after).Order(StringComparer.Ordinal).ToArray(), + WechatLocators.RequiredAutomationIds.Where(id => !after.Contains(id)).ToArray()); + } +} diff --git a/src/WxAgent.Core/WechatLocators.cs b/src/WxAgent.Core/WechatLocators.cs index 7e3849f..97958e3 100644 --- a/src/WxAgent.Core/WechatLocators.cs +++ b/src/WxAgent.Core/WechatLocators.cs @@ -3,6 +3,16 @@ namespace WxAgent.Core; public static class WechatLocators { public const string MainView = "MainView"; + public const string MainTabBar = "MainView.main_tabbar"; + + public static bool IsWechatTrayName(string name) => + name is "微信" or "WeChat" or "Weixin" + || name.StartsWith("微信\n", StringComparison.Ordinal) + || name.StartsWith("微信\r", StringComparison.Ordinal); + + public static bool IsNativeMainWindow(string className, string title) => + title == "微信" && className.StartsWith("Qt", StringComparison.Ordinal) + && className.EndsWith("QWindowIcon", StringComparison.Ordinal); public const string SessionList = "session_list"; public const string ChatPage = "chat_message_page"; public const string MessageList = "chat_message_list"; diff --git a/src/WxAgent.Core/WechatStability.cs b/src/WxAgent.Core/WechatStability.cs new file mode 100644 index 0000000..8915d74 --- /dev/null +++ b/src/WxAgent.Core/WechatStability.cs @@ -0,0 +1,85 @@ +using System.Text.Json; + +namespace WxAgent.Core; + +public sealed record WechatStabilitySample( + DateTimeOffset Timestamp, + int ProcessCount, + long WorkingSetBytes, + int HandleCount, + int ThreadCount, + bool WindowFound, + IReadOnlyList Errors, + long MessageEvents, + long ReconnectEvents, + long AgentWorkingSetBytes = 0, + int AgentHandleCount = 0, + int AgentThreadCount = 0, + DateTimeOffset? LastSnapshotAt = null); + +public sealed record WechatStabilityReport( + DateTimeOffset StartedAt, + DateTimeOffset FinishedAt, + IReadOnlyList Samples, + long MessageEvents, + long ReconnectEvents, + long WorkingSetGrowthBytes, + int HandleGrowth, + int ThreadGrowth, + bool Healthy, + IReadOnlyList Findings, + double RequestedSeconds = 0, + double ObservedSeconds = 0, + bool CompletedDuration = false); + +public static class WechatStabilityAnalyzer +{ + public static WechatStabilityReport Analyze( + DateTimeOffset startedAt, + DateTimeOffset finishedAt, + IReadOnlyList samples, + long messageEvents, + long reconnectEvents) + { + ArgumentNullException.ThrowIfNull(samples); + if (samples.Count == 0) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "At least one stability sample is required."); + + var first = samples[0]; + var last = samples[^1]; + var memoryGrowth = last.WorkingSetBytes - first.WorkingSetBytes; + var handleGrowth = last.HandleCount - first.HandleCount; + var threadGrowth = last.ThreadCount - first.ThreadCount; + var findings = new List(); + var recoveredWindowLoss = last.WindowFound && reconnectEvents > 0; + if (samples.Any(sample => !sample.WindowFound) && !recoveredWindowLoss) findings.Add("wechat-window-unavailable"); + foreach (var error in last.Errors) findings.Add("final-error:" + error); + if (last.AgentWorkingSetBytes - first.AgentWorkingSetBytes > 128L * 1024 * 1024) findings.Add("agent-working-set-growth-over-128mb"); + if (last.AgentHandleCount - first.AgentHandleCount > 100) findings.Add("agent-handle-growth-over-100"); + if (last.AgentThreadCount - first.AgentThreadCount > 20) findings.Add("agent-thread-growth-over-20"); + if (memoryGrowth > 256L * 1024 * 1024) findings.Add("working-set-growth-over-256mb"); + if (handleGrowth > 500) findings.Add("handle-growth-over-500"); + if (threadGrowth > 100) findings.Add("thread-growth-over-100"); + if (samples.Any(sample => sample.LastSnapshotAt is { } lastSnapshot + && sample.Timestamp - lastSnapshot > TimeSpan.FromMinutes(5))) findings.Add("listener-snapshot-gap-over-5min"); + + return new WechatStabilityReport( + startedAt, + finishedAt, + samples, + messageEvents, + reconnectEvents, + memoryGrowth, + handleGrowth, + threadGrowth, + findings.Count == 0, + findings); + } + + public static async Task WriteJsonAsync(WechatStabilityReport report, string path, CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await File.WriteAllTextAsync(fullPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), cancellationToken); + } +} diff --git a/src/WxAgent.Host/Program.cs b/src/WxAgent.Host/Program.cs index 6dd00c6..fef780e 100644 --- a/src/WxAgent.Host/Program.cs +++ b/src/WxAgent.Host/Program.cs @@ -35,6 +35,33 @@ try WriteJson(report); return report.Errors.Count == 0 ? 0 : 2; } + case "diagnose": + { + var directory = Path.GetFullPath(GetRequiredOption(args, "--output")); + Directory.CreateDirectory(directory); + var snapshot = await WechatUiInspector.CaptureAsync(Path.Combine(directory, "ui-tree.json"), cancellationToken); + var baseline = snapshot; + if (GetOption(args, "--baseline") is { } baselinePath) + { + await using var input = File.OpenRead(baselinePath); + baseline = await JsonSerializer.DeserializeAsync(input, + new JsonSerializerOptions { MaxDepth = 128 }, cancellationToken) + ?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Baseline UI snapshot is empty."); + } + var report = new + { + correlationId = Guid.NewGuid().ToString("N"), + stage = "ui-compatibility-diagnostics", + capturedAt = DateTimeOffset.UtcNow, + doctor = WechatDoctor.Run(cancellationToken), + nativeWindows = WechatChatClient.InspectNativeWindows(), + comparison = UiSnapshotComparison.Compare(baseline, snapshot) + }; + await File.WriteAllTextAsync(Path.Combine(directory, "diagnostics.json"), + JsonSerializer.Serialize(report, jsonOptions), cancellationToken); + WriteJson(report); + return report.doctor.Errors.Count == 0 && report.comparison.MissingRequiredControls.Count == 0 ? 0 : 2; + } case "inspect-ui": { var output = GetRequiredOption(args, "--output"); @@ -62,6 +89,78 @@ try }); return report.Errors.Count == 0 ? 0 : 2; } + case "tray-status": + { + WriteJson(WechatChatClient.InspectTray(cancellationToken)); + return 0; + } + case "window-status": + { + WriteJson(WechatChatClient.InspectNativeWindows()); + return 0; + } + case "listener-smoke": + { + var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken); + WriteJson(result); + return result.Sent && result.MatchingEvents == 1 && result.EventsAfterRestart == 0 + && result.Snapshots >= 2 && result.CheckpointSaved ? 0 : 2; + } + case "recovery-smoke": + { + var checks = await WechatChatClient.RecoverySmokeAsync(cancellationToken); + WriteJson(checks); + return checks.All(check => check.TransitionObserved && check.SameInstance && check.MainViewFound + && check.SessionsFound && check.MessagesFound) ? 0 : 2; + } + case "recover-ui": + { + var result = await WechatChatClient.RecoverUiAsync(cancellationToken); + WriteJson(result); + return result.Success ? 0 : 2; + } + case "stability-smoke": + { + var seconds = GetPositiveIntOption(args, "--seconds", 15, 300); + var sampleSeconds = GetPositiveIntOption(args, "--sample-seconds", 5, seconds); + var output = GetOption(args, "--output") ?? Path.Combine("artifacts", "m6-stability-smoke"); + var recovery = await WechatChatClient.RecoverUiAsync(cancellationToken); + var report = await WechatStabilityRunner.RunAsync( + TimeSpan.FromSeconds(seconds), + TimeSpan.FromSeconds(sampleSeconds), + output, + cancellationToken); + WriteJson(new + { + scope = "M6", + recovery.Success, + report.Healthy, + sampleCount = report.Samples.Count, + report.MessageEvents, + report.ReconnectEvents, + report.WorkingSetGrowthBytes, + report.HandleGrowth, + report.ThreadGrowth, + report.Findings, + output = Path.GetFullPath(output), + privacy = "message content omitted" + }); + return report.Healthy ? 0 : 2; + } + case "endurance": + { + var hours = GetPositiveIntOption(args, "--hours", 24, 72); + var sampleSeconds = GetPositiveIntOption(args, "--sample-seconds", 60, 3600); + var output = GetOption(args, "--output") ?? Path.Combine("artifacts", $"endurance-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"); + await WechatChatClient.RecoverUiAsync(cancellationToken); + var report = await WechatStabilityRunner.RunAsync( + TimeSpan.FromHours(hours), + TimeSpan.FromSeconds(sampleSeconds), + output, + cancellationToken); + WriteJson(report with { Samples = [] }); + return report.Healthy ? 0 : 2; + } case "m5-smoke": { var momentsWindow = await WechatChatClient.OpenMomentsAsync(cancellationToken); @@ -372,6 +471,18 @@ static int ValidateCommandLine(string[] values) return 30; } + if (values[0] == "listener-smoke") + { + ValidateOptions(values, 1, ["--output", "--timeout"], []); + return 60; + } + + if (values[0] == "diagnose") + { + ValidateOptions(values, 1, ["--output", "--baseline", "--timeout"], []); + return 60; + } + if (values[0] == "inspect-ui") { ValidateOptions(values, 1, ["--output", "--timeout"], []); @@ -384,6 +495,24 @@ static int ValidateCommandLine(string[] values) return 30; } + if (values[0] is "recover-ui" or "window-status" or "tray-status" or "recovery-smoke") + { + ValidateOptions(values, 1, ["--timeout"], []); + return 30; + } + + if (values[0] == "stability-smoke") + { + ValidateOptions(values, 1, ["--seconds", "--sample-seconds", "--output", "--timeout"], []); + return GetPositiveIntOption(values, "--seconds", 15, 300) + 60; + } + + if (values[0] == "endurance") + { + ValidateOptions(values, 1, ["--hours", "--sample-seconds", "--output", "--timeout"], []); + return checked(GetPositiveIntOption(values, "--hours", 24, 72) * 3600 + 120); + } + if (values[0] == "m5-smoke") { ValidateOptions(values, 1, ["--timeout"], []); @@ -586,10 +715,18 @@ static int CountNodes(UiNodeSnapshot node) => 1 + node.Children.Sum(CountNodes); static void PrintHelp() => Console.WriteLine(""" WxAgent.Host commands: doctor [--timeout 30] + diagnose --output [--baseline ] [--timeout 60] inspect-ui --output [--timeout 30] smoke [--output ] [--timeout 30] m4-smoke [--timeout 60] m5-smoke [--timeout 60] + tray-status [--timeout 30] + window-status [--timeout 30] + listener-smoke --output [--timeout 60] + recovery-smoke [--timeout 30] + recover-ui [--timeout 30] + stability-smoke [--seconds 15] [--sample-seconds 5] [--output ] + endurance [--hours 24] [--sample-seconds 60] [--output ] group at-all --group --message --confirm CONFIRM [--timeout 30] group verify-at-all --group --message [--timeout 30] chat send-url-card --to --url [--message ] --confirm CONFIRM [--timeout 60] diff --git a/src/WxAgent.Windows/NativeMethods.txt b/src/WxAgent.Windows/NativeMethods.txt index e618e2a..6d2a5ed 100644 --- a/src/WxAgent.Windows/NativeMethods.txt +++ b/src/WxAgent.Windows/NativeMethods.txt @@ -3,3 +3,14 @@ CloseHandle VirtualQueryEx ReadProcessMemory QueryFullProcessImageName +EnumWindows +GetWindowThreadProcessId +GetWindowText +GetClassName +IsWindowVisible +IsIconic +GetDpiForWindow +GetWindowRect +AccessibleObjectFromWindow +AccessibleChildren +EnumChildWindows diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index a5435c0..45b97de 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -416,7 +416,8 @@ public static partial class WechatChatClient TimeSpan duration, string? checkpointPath, [EnumeratorCancellation] CancellationToken cancellationToken, - IReadOnlyList>? callbacks = null) + IReadOnlyList>? callbacks = null, + Action? snapshotObserved = null) { if (duration <= TimeSpan.Zero) { @@ -448,33 +449,31 @@ public static partial class WechatChatClient IReadOnlyList? visible = null; try { - window = WechatDoctor.FindWechatWindow(automation); - if (window is not null) + await CommandQueue.WaitAsync(cancellationToken); + try { + window = AttachWindow(automation); + if (!IsFileTransferAssistantOpen(window)) connected = false; if (!connected) { - await CommandQueue.WaitAsync(cancellationToken); - try - { - await OpenFileTransferAssistantCoreAsync(window, cancellationToken); - } - finally - { - CommandQueue.Release(); - } - + await OpenFileTransferAssistantCoreAsync(window, cancellationToken); subscription?.Dispose(); - var list = FindByAutomationId(window, WechatLocators.MessageList); - subscription = list?.RegisterStructureChangedEvent( + var list = FindByAutomationId(window, WechatLocators.MessageList) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Listener message list was not found."); + subscription = list.RegisterStructureChangedEvent( TreeScope.Subtree, (_, _, _) => signals.Writer.TryWrite(true)); connected = true; } + // Never attribute the currently selected contact's messages to File Transfer Assistant. + if (!IsFileTransferAssistantOpen(window)) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener session changed during snapshot."); visible = ReadVisible(window); + snapshotObserved?.Invoke(); } - else + finally { - connected = false; + CommandQueue.Release(); } } catch (OperationCanceledException) @@ -581,11 +580,18 @@ public static partial class WechatChatClient private static AutomationElement AttachWindow(UIA3Automation automation) { var window = WechatDoctor.FindWechatWindow(automation); - if (window is null) + if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null) { - throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat main window was not found in the current interactive session."); + if (!WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, CancellationToken.None); + for (var attempt = 0; attempt < 50; attempt++) + { + Thread.Sleep(100); + window = WechatDoctor.FindWechatWindow(automation); + if (window is not null && FindByAutomationId(window, WechatLocators.MainView) is not null) break; + } } - + if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat tray activation did not restore an accessible main view; no new process was started."); window.Focus(); return window; } diff --git a/src/WxAgent.Windows/WechatDoctor.cs b/src/WxAgent.Windows/WechatDoctor.cs index 02d318a..afe8dae 100644 --- a/src/WxAgent.Windows/WechatDoctor.cs +++ b/src/WxAgent.Windows/WechatDoctor.cs @@ -6,7 +6,15 @@ using WxAgent.Core; namespace WxAgent.Windows; -public sealed record ProcessDiagnostic(int Id, long WorkingSetBytes, int SessionId, string? Version, bool CanReadMemory, string? AccessError); +public sealed record ProcessDiagnostic( + int Id, + long WorkingSetBytes, + int SessionId, + string? Version, + bool CanReadMemory, + string? AccessError, + int HandleCount, + int ThreadCount); public sealed record DoctorReport( string OperatingSystem, @@ -24,10 +32,25 @@ public static class WechatDoctor { cancellationToken.ThrowIfCancellationRequested(); var errors = new List(); - var processes = Process.GetProcessesByName("Weixin") - .OrderByDescending(process => process.WorkingSet64) - .Select(process => CreateProcessDiagnostic(process, cancellationToken)) - .ToArray(); + using var currentProcess = Process.GetCurrentProcess(); + var rawProcesses = Process.GetProcessesByName("Weixin"); + ProcessDiagnostic[] processes; + try + { + var diagnostics = new List(); + foreach (var process in rawProcesses) + { + cancellationToken.ThrowIfCancellationRequested(); + try { diagnostics.Add(CreateProcessDiagnostic(process, cancellationToken)); } + catch (InvalidOperationException) { /* Process exited during sampling. */ } + catch (System.ComponentModel.Win32Exception) { /* Process is no longer accessible. */ } + } + processes = diagnostics.OrderByDescending(process => process.WorkingSetBytes).ToArray(); + } + finally + { + foreach (var process in rawProcesses) process.Dispose(); + } if (processes.Length == 0) { @@ -65,7 +88,7 @@ public static class WechatDoctor if (!windowFound && processes.Length > 0) { - errors.Add(processes.Any(process => process.SessionId != Process.GetCurrentProcess().SessionId) + errors.Add(processes.All(process => process.SessionId != currentProcess.SessionId) ? WxAgentErrorCode.SessionLocked : WxAgentErrorCode.WindowNotFound); } @@ -83,7 +106,7 @@ public static class WechatDoctor return new DoctorReport( Environment.OSVersion.VersionString, Environment.UserInteractive, - Process.GetCurrentProcess().SessionId, + currentProcess.SessionId, processes, windowFound, controls, @@ -91,9 +114,27 @@ public static class WechatDoctor errors.Distinct().ToArray()); } - internal static FlaUI.Core.AutomationElements.AutomationElement? FindWechatWindow(AutomationBase automation) => - automation.GetDesktop().FindAllChildren(condition => condition.ByControlType(ControlType.Window)) - .FirstOrDefault(element => string.Equals(element.Name, "微信", StringComparison.Ordinal)); + internal static FlaUI.Core.AutomationElements.AutomationElement? FindWechatWindow(AutomationBase automation) + { + FlaUI.Core.AutomationElements.AutomationElement? fallback = null; + var mainWindows = WechatNativeWindow.Enumerate().Where(window => + window.Visible && !window.Minimized && WechatLocators.IsNativeMainWindow(window.ClassName, window.Title)); + foreach (var native in mainWindows) + { + try + { + var candidate = automation.FromHandle((nint)native.Handle); + if (candidate.FindFirstDescendant(condition => condition.ByAutomationId(WechatLocators.MainView)) is not null) + return candidate; + fallback ??= candidate; + } + catch + { + // Ignore transient or closing subwindows. + } + } + return fallback; + } private static ProcessDiagnostic CreateProcessDiagnostic(Process process, CancellationToken cancellationToken) { @@ -108,6 +149,10 @@ public static class WechatDoctor } var access = ProcessMemoryScanner.Probe(process.Id); - return new ProcessDiagnostic(process.Id, process.WorkingSet64, process.SessionId, version, access.Success, access.Error); + var handleCount = 0; + var threadCount = 0; + try { handleCount = process.HandleCount; } catch { } + try { threadCount = process.Threads.Count; } catch { } + return new ProcessDiagnostic(process.Id, process.WorkingSet64, process.SessionId, version, access.Success, access.Error, handleCount, threadCount); } } diff --git a/src/WxAgent.Windows/WechatListenerSmoke.cs b/src/WxAgent.Windows/WechatListenerSmoke.cs new file mode 100644 index 0000000..dfb4331 --- /dev/null +++ b/src/WxAgent.Windows/WechatListenerSmoke.cs @@ -0,0 +1,58 @@ +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record WechatListenerSmokeResult(bool Sent, int MatchingEvents, int EventsAfterRestart, + int Snapshots, bool CheckpointSaved); + +public static partial class WechatChatClient +{ + public static async Task ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken) + { + var directory = Path.GetFullPath(outputDirectory); + Directory.CreateDirectory(directory); + var checkpoint = Path.Combine(directory, "listener-checkpoint.json"); + if (File.Exists(checkpoint)) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Use a new listener smoke directory."); + var marker = "wx-agent-m6-listener-" + Guid.NewGuid().ToString("N"); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var matched = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var matches = 0; + var snapshots = 0; + using var listening = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + listening.CancelAfter(TimeSpan.FromSeconds(30)); + var reader = Task.Run(async () => + { + try + { + await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(30), checkpoint, listening.Token, + snapshotObserved: () => { Interlocked.Increment(ref snapshots); ready.TrySetResult(); })) + { + if (item.Message?.Text != marker) continue; + Interlocked.Increment(ref matches); + matched.TrySetResult(); + } + } + catch (OperationCanceledException) when (listening.IsCancellationRequested) { } + }, CancellationToken.None); + try + { + await ready.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); + await SendTextAsync(marker, cancellationToken).ConfigureAwait(false); // One send only, through the same command queue. + await matched.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false); + } + finally + { + listening.Cancel(); + await reader.ConfigureAwait(false); + } + var afterRestart = 0; + await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(4), checkpoint, cancellationToken, + snapshotObserved: () => Interlocked.Increment(ref snapshots))) + { + if (item.Message?.Text == marker) afterRestart++; + } + return new WechatListenerSmokeResult(true, matches, afterRestart, snapshots, File.Exists(checkpoint)); + } +} diff --git a/src/WxAgent.Windows/WechatNativeWindow.cs b/src/WxAgent.Windows/WechatNativeWindow.cs new file mode 100644 index 0000000..40aa2ff --- /dev/null +++ b/src/WxAgent.Windows/WechatNativeWindow.cs @@ -0,0 +1,52 @@ +using System.Diagnostics; +using Windows.Win32; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record WechatWindowDiagnostic(long Handle, int ProcessId, string ClassName, string Title, + bool Visible, bool Minimized, uint Dpi, int Width, int Height); + +internal static class WechatNativeWindow +{ + internal static unsafe IReadOnlyList Enumerate() + { + using var current = Process.GetCurrentProcess(); + var processIds = new HashSet(); + foreach (var process in Process.GetProcessesByName("Weixin")) + { + using (process) + { + try { if (process.SessionId == current.SessionId) processIds.Add(process.Id); } + catch (InvalidOperationException) { } + } + } + var result = new List(); + PInvoke.EnumWindows((hwnd, _) => + { + uint pid; + PInvoke.GetWindowThreadProcessId(hwnd, &pid); + if (!processIds.Contains((int)pid)) return true; + Span className = stackalloc char[256]; + Span title = stackalloc char[512]; + var classLength = PInvoke.GetClassName(hwnd, className); + var titleLength = PInvoke.GetWindowText(hwnd, title); + PInvoke.GetWindowRect(hwnd, out var rect); + result.Add(new WechatWindowDiagnostic((long)(nint)hwnd.Value, (int)pid, + new string(className[..classLength]), new string(title[..titleLength]), + PInvoke.IsWindowVisible(hwnd), PInvoke.IsIconic(hwnd), PInvoke.GetDpiForWindow(hwnd), + rect.right - rect.left, rect.bottom - rect.top)); + return true; + }, default); + return result; + } + + internal static WechatWindowDiagnostic Sanitize(WechatWindowDiagnostic window) => + window with { Title = UiSnapshotSanitizer.SanitizeName(window.Title) }; +} + +public static partial class WechatChatClient +{ + public static IReadOnlyList InspectNativeWindows() => + WechatNativeWindow.Enumerate().Select(WechatNativeWindow.Sanitize).ToArray(); +} diff --git a/src/WxAgent.Windows/WechatRecoverySmoke.cs b/src/WxAgent.Windows/WechatRecoverySmoke.cs new file mode 100644 index 0000000..3d0aa05 --- /dev/null +++ b/src/WxAgent.Windows/WechatRecoverySmoke.cs @@ -0,0 +1,45 @@ +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record WechatRecoveryCheck(string State, bool TransitionObserved, bool SameInstance, + bool MainViewFound, bool SessionsFound, bool MessagesFound); + +public static partial class WechatChatClient +{ + public static async Task> RecoverySmokeAsync(CancellationToken cancellationToken) + { + var checks = new List(); + foreach (var state in new[] { "minimized", "tray-hidden" }) + { + var identity = await WithMainWindowAsync(async (main, _) => + { + await OpenFileTransferAssistantCoreAsync(main, cancellationToken).ConfigureAwait(false); + var native = main.Properties.NativeWindowHandle.Value; + var processId = main.Properties.ProcessId.Value; + if (state == "minimized") main.AsWindow().Patterns.Window.Pattern.SetWindowVisualState(WindowVisualState.Minimized); + else main.AsWindow().Close(); // Normal WeChat close-to-tray, not SW_HIDE or process termination. + return (Handle: native.ToInt64(), ProcessId: processId); + }, cancellationToken).ConfigureAwait(false); + + var transitioned = false; + for (var attempt = 0; attempt < 30; attempt++) + { + var native = WechatNativeWindow.Enumerate().FirstOrDefault(window => window.Handle == identity.Handle && window.ProcessId == identity.ProcessId); + transitioned = native is not null && (state == "minimized" ? native.Minimized : !native.Visible); + if (transitioned) break; + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + await RecoverUiAsync(cancellationToken).ConfigureAwait(false); + checks.Add(await WithMainWindowAsync((main, _) => Task.FromResult(new WechatRecoveryCheck( + state, transitioned, main.Properties.NativeWindowHandle.Value.ToInt64() == identity.Handle + && main.Properties.ProcessId.Value == identity.ProcessId, + FindByAutomationId(main, WechatLocators.MainView) is not null, + FindByAutomationId(main, WechatLocators.SessionList) is not null, + FindByAutomationId(main, WechatLocators.MessageList) is not null)), cancellationToken).ConfigureAwait(false)); + } + return checks; + } +} diff --git a/src/WxAgent.Windows/WechatStabilityRunner.cs b/src/WxAgent.Windows/WechatStabilityRunner.cs new file mode 100644 index 0000000..67e897c --- /dev/null +++ b/src/WxAgent.Windows/WechatStabilityRunner.cs @@ -0,0 +1,192 @@ +using System.Diagnostics; +using System.Text.Json; +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Definitions; +using FlaUI.Core.Input; +using FlaUI.Core.WindowsAPI; +using FlaUI.UIA3; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public static class WechatStabilityRunner +{ + public static async Task RunAsync( + TimeSpan duration, + TimeSpan sampleInterval, + string outputDirectory, + CancellationToken cancellationToken) + { + if (duration <= TimeSpan.Zero) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "duration must be positive."); + if (sampleInterval < TimeSpan.FromSeconds(1) || sampleInterval > duration || duration / sampleInterval > 10_000) + throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "sampleInterval must be at least one second, no longer than duration, and produce at most 10000 samples."); + + var directory = Path.GetFullPath(outputDirectory); + Directory.CreateDirectory(directory); + var samplesPath = Path.Combine(directory, "samples.jsonl"); + var checkpointPath = Path.Combine(directory, "listener-checkpoint.json"); + await using var sampleStream = new FileStream(samplesPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(sampleStream) { AutoFlush = true }; + + var startedAt = DateTimeOffset.UtcNow; + long messageEvents = 0; + long reconnectEvents = 0; + long snapshotTicks = 0; + Exception? listenerError = null; + Exception? samplingError = null; + var elapsed = Stopwatch.StartNew(); + var milestones = new HashSet(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linked.CancelAfter(duration); + + var listener = Task.Run(async () => + { + try + { + await foreach (var messageEvent in WechatChatClient.ListenEventsAsync(duration, checkpointPath, linked.Token, + snapshotObserved: () => Interlocked.Exchange(ref snapshotTicks, DateTimeOffset.UtcNow.Ticks))) + { + if (messageEvent.Kind == MessageEventKind.MessageReceived) + Interlocked.Increment(ref messageEvents); + else if (messageEvent.Kind == MessageEventKind.Reconnected) + Interlocked.Increment(ref reconnectEvents); + } + } + catch (OperationCanceledException) when (linked.IsCancellationRequested) + { + } + catch (Exception exception) + { + Volatile.Write(ref listenerError, exception); + } + }, CancellationToken.None); + + var samples = new List(); + try + { + while (!linked.IsCancellationRequested) + { + var sample = CaptureSample(Interlocked.Read(ref messageEvents), Interlocked.Read(ref reconnectEvents), + Volatile.Read(ref listenerError), Interlocked.Read(ref snapshotTicks)); + samples.Add(sample); + await writer.WriteLineAsync(JsonSerializer.Serialize(sample)).ConfigureAwait(false); + foreach (var hours in new[] { 8, 24, 72 }) + { + if (elapsed.Elapsed < TimeSpan.FromHours(hours) || !milestones.Add(hours)) continue; + var milestone = WechatStabilityAnalyzer.Analyze(startedAt, DateTimeOffset.UtcNow, samples.ToArray(), messageEvents, reconnectEvents) + with { RequestedSeconds = hours * 3600, ObservedSeconds = elapsed.Elapsed.TotalSeconds, CompletedDuration = true }; + await WechatStabilityAnalyzer.WriteJsonAsync(milestone, Path.Combine(directory, $"report-{hours}h.json"), linked.Token).ConfigureAwait(false); + } + if (listener.IsCompleted && elapsed.Elapsed + TimeSpan.FromSeconds(1) < duration) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener stopped before endurance duration elapsed."); + await Task.Delay(sampleInterval, linked.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (linked.IsCancellationRequested) { } + catch (Exception exception) { samplingError = exception; } + finally + { + linked.Cancel(); + await listener.ConfigureAwait(false); + } + + samples.Add(CaptureSample(messageEvents, reconnectEvents, listenerError, snapshotTicks)); + await writer.WriteLineAsync(JsonSerializer.Serialize(samples[^1])).ConfigureAwait(false); + var report = WechatStabilityAnalyzer.Analyze(startedAt, DateTimeOffset.UtcNow, samples, messageEvents, reconnectEvents); + var findings = report.Findings.ToList(); + var completed = elapsed.Elapsed + TimeSpan.FromSeconds(1) >= duration && !cancellationToken.IsCancellationRequested; + if (!completed) findings.Add("duration-incomplete"); + if (listenerError is not null) findings.Add("listener-failed:" + listenerError.GetType().Name); + if (samplingError is not null) findings.Add("sampling-failed:" + samplingError.GetType().Name); + if (samples[^1].LastSnapshotAt is null || DateTimeOffset.UtcNow - samples[^1].LastSnapshotAt > TimeSpan.FromSeconds(10)) + findings.Add("listener-snapshot-stale"); + report = report with { Healthy = findings.Count == 0, Findings = findings, RequestedSeconds = duration.TotalSeconds, + ObservedSeconds = elapsed.Elapsed.TotalSeconds, CompletedDuration = completed }; + using var reportTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await WechatStabilityAnalyzer.WriteJsonAsync(report, Path.Combine(directory, "report.json"), reportTimeout.Token).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return report; + } + + private static WechatStabilitySample CaptureSample(long messageEvents, long reconnectEvents, Exception? listenerError, long snapshotTicks) + { + using var agent = Process.GetCurrentProcess(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var doctor = WechatDoctor.Run(timeout.Token); + var errors = doctor.Errors.ToList(); + if (listenerError is not null) errors.Add(WxAgentErrorCode.InvalidOperationState); + return new WechatStabilitySample( + DateTimeOffset.UtcNow, + doctor.Processes.Count, + doctor.Processes.Sum(process => process.WorkingSetBytes), + doctor.Processes.Sum(process => process.HandleCount), + doctor.Processes.Sum(process => process.ThreadCount), + doctor.WindowFound, + errors.Distinct().ToArray(), + messageEvents, + reconnectEvents, + agent.WorkingSet64, + agent.HandleCount, + agent.Threads.Count, + snapshotTicks == 0 ? null : new DateTimeOffset(snapshotTicks, TimeSpan.Zero)); + } +} + +public static partial class WechatChatClient +{ + public static async Task RecoverUiAsync(CancellationToken cancellationToken = default) + { + await CommandQueue.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + using var automation = new UIA3Automation(); + cancellationToken.ThrowIfCancellationRequested(); + var main = AttachWindow(automation); + if (FindByAutomationId(main, WechatLocators.SessionList) is not null) + return WechatOperationResult.Ok("WeChat UI is already on chats"); + + // Escape on WeChat's main page hides the application to the tray; never press it blindly. + var tabbar = FindByAutomationId(main, WechatLocators.MainTabBar) ?? main; + var chats = tabbar.FindFirstDescendant(cf => cf.ByName("微信").And(cf.ByControlType(ControlType.Button))) + ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The WeChat navigation button was not found."); + InvokeOrClick(chats); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + if (FindByAutomationId(main, WechatLocators.SessionList) is null) chats.Click(); + + for (var attempt = 0; attempt < 30; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var fresh = WechatDoctor.FindWechatWindow(automation); + if (fresh is not null && FindByAutomationId(fresh, WechatLocators.SessionList) is not null) + return WechatOperationResult.Ok("WeChat UI recovered to chats"); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "WeChat did not recover to the chats page."); + } + catch (OperationCanceledException) { throw; } + catch (WxAgentException) + { + throw; + } + catch (Exception exception) + { + throw new WxAgentException( + WxAgentErrorCode.InvalidOperationState, + $"WeChat UI recovery failed: {exception.GetType().Name}: {exception.Message}", + exception); + } + finally + { + CommandQueue.Release(); + } + } + + private static void InvokeOrClick(AutomationElement element) + { + if (element.Patterns.SelectionItem.IsSupported) element.Patterns.SelectionItem.Pattern.Select(); + if (element.Patterns.LegacyIAccessible.IsSupported) element.Patterns.LegacyIAccessible.Pattern.DoDefaultAction(); + else if (element.Patterns.Invoke.IsSupported) element.Patterns.Invoke.Pattern.Invoke(); + else element.Click(); + } +} diff --git a/src/WxAgent.Windows/WechatTray.cs b/src/WxAgent.Windows/WechatTray.cs new file mode 100644 index 0000000..b6fdbc2 --- /dev/null +++ b/src/WxAgent.Windows/WechatTray.cs @@ -0,0 +1,109 @@ +using System.Runtime.InteropServices; +using FlaUI.Core.Input; +using FlaUI.Core.Definitions; +using FlaUI.UIA3; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Accessibility; +using WxAgent.Core; + +namespace WxAgent.Windows; + +public sealed record WechatTrayDiagnostic(int ToolbarCount, int IconCount, int WechatIconCount); + +internal static class WechatTray +{ + internal static bool TryActivateUia(UIA3Automation automation) + { + var regions = automation.GetDesktop().FindAllChildren(cf => cf.ByClassName("Shell_TrayWnd")) + .SelectMany(window => window.FindAllDescendants(cf => cf.ByClassName("TrayNotifyWnd"))); + var matches = regions.SelectMany(region => region.FindAllDescendants(cf => cf.ByControlType(ControlType.Button))) + .Where(icon => WechatLocators.IsWechatTrayName(icon.Name) + && !icon.IsOffscreen && !icon.BoundingRectangle.IsEmpty).ToArray(); + if (matches.Length == 0) return false; + if (matches.Length != 1) + throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Multiple WeChat notification icons found; refusing an ambiguous target."); + matches[0].DoubleClick(); + return true; + } + + // On Windows 10 the notification toolbar may expose no buttons through UIA3. + // Use its public MSAA accessibility API, never Explorer process memory or tray callback message guesses. + internal static unsafe WechatTrayDiagnostic Inspect(bool activate, CancellationToken cancellationToken) + { + var toolbars = new List(); + PInvoke.EnumWindows((root, _) => + { + var className = ReadClass(root); + if (className is not ("Shell_TrayWnd" or "NotifyIconOverflowWindow")) return true; + PInvoke.EnumChildWindows(root, (child, _) => + { + if (ReadClass(child) == "ToolbarWindow32") toolbars.Add(child); + return true; + }, default); + return true; + }, default); + var icons = 0; + var matches = new List<(IAccessible Owner, object Child)>(); + var owners = new List(); + try + { + foreach (var toolbar in toolbars) + { + cancellationToken.ThrowIfCancellationRequested(); + void* pointer; + var hr = PInvoke.AccessibleObjectFromWindow(toolbar, 0xfffffffc, typeof(IAccessible).GUID, out pointer); + if (hr.Value < 0 || pointer == null) continue; + IAccessible owner; + try { owner = (IAccessible)Marshal.GetObjectForIUnknown((nint)pointer); } + finally { Marshal.Release((nint)pointer); } + owners.Add(owner); + var count = Math.Clamp(owner.accChildCount, 0, 256); + var children = new object[count]; + PInvoke.AccessibleChildren(owner, 0, children, out var obtained); + for (var index = 0; index < obtained; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var child = children[index]; + if (child is not int) continue; + icons++; + var name = owner.get_accName(child); + string text; + try { text = name.ToString() ?? string.Empty; } + finally { if (name.Value != null) Marshal.FreeBSTR((nint)name.Value); } + if (WechatLocators.IsWechatTrayName(text)) matches.Add((owner, child)); + } + } + if (activate) + { + if (matches.Count != 1) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, + $"Expected one WeChat notification icon; found {matches.Count}. No application was started."); + var match = matches[0]; + match.Owner.accLocation(out var left, out var top, out var width, out var height, match.Child); + if (width <= 0 || height <= 0 || (Convert.ToInt32(match.Owner.get_accState(match.Child)) & 0x10000) != 0) + throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The WeChat tray icon is in the hidden overflow area; reveal it before restoring."); + // Qt opens the original main window on a tray double-click, not MSAA's single default action. + Mouse.MoveTo(left + width / 2, top + height / 2); + Mouse.DoubleClick(MouseButton.Left); + } + return new WechatTrayDiagnostic(toolbars.Count, icons, matches.Count); + } + finally + { + foreach (var owner in owners) Marshal.ReleaseComObject(owner); + } + } + + private static string ReadClass(HWND hwnd) + { + Span text = stackalloc char[256]; + return new string(text[..PInvoke.GetClassName(hwnd, text)]); + } +} + +public static partial class WechatChatClient +{ + public static WechatTrayDiagnostic InspectTray(CancellationToken cancellationToken = default) => + WechatTray.Inspect(false, cancellationToken); +} diff --git a/src/WxAgent.Windows/WechatUiInspector.cs b/src/WxAgent.Windows/WechatUiInspector.cs index 3a2f31a..2631af8 100644 --- a/src/WxAgent.Windows/WechatUiInspector.cs +++ b/src/WxAgent.Windows/WechatUiInspector.cs @@ -16,31 +16,38 @@ public static class WechatUiInspector var window = WechatDoctor.FindWechatWindow(automation) ?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat window was not found in the current interactive session."); - var snapshot = Capture(window, cancellationToken); + var guard = new UiTraversalGuard(); + var snapshot = Capture(window, cancellationToken, guard, 0); var fullPath = Path.GetFullPath(outputPath); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); await using var stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous); - await JsonSerializer.SerializeAsync(stream, snapshot, new JsonSerializerOptions { WriteIndented = true }, cancellationToken); + await JsonSerializer.SerializeAsync(stream, snapshot, new JsonSerializerOptions { WriteIndented = true, MaxDepth = 128 }, cancellationToken); + if (guard.Truncated || guard.CycleDetected) + throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "UI snapshot was saved partially: traversal limit or repeated runtime ID detected."); return snapshot; } - private static UiNodeSnapshot Capture(AutomationElement element, CancellationToken cancellationToken) + private static UiNodeSnapshot Capture( + AutomationElement element, + CancellationToken cancellationToken, + UiTraversalGuard guard, + int depth) { cancellationToken.ThrowIfCancellationRequested(); + var identity = SafeRead(() => string.Join(',', element.Properties.RuntimeId.ValueOrDefault ?? [])); + var canDescend = guard.TryVisit(identity, depth); var children = new List(); - AutomationElement[] rawChildren; - try + if (canDescend) { - rawChildren = element.FindAllChildren(); - } - catch - { - rawChildren = []; - } + AutomationElement[] rawChildren; + try { rawChildren = element.FindAllChildren(); } + catch { rawChildren = []; } - foreach (var child in rawChildren) - { - children.Add(Capture(child, cancellationToken)); + foreach (var child in rawChildren) + { + if (guard.Truncated) break; + children.Add(Capture(child, cancellationToken, guard, depth + 1)); + } } string bounds; @@ -55,7 +62,7 @@ public static class WechatUiInspector } return new UiNodeSnapshot( - element.ControlType.ToString(), + SafeRead(() => element.ControlType.ToString()), UiSnapshotSanitizer.SanitizeName(SafeRead(() => element.Name)), UiSnapshotSanitizer.SanitizeAutomationId(SafeRead(() => element.AutomationId)), SafeRead(() => element.IsEnabled, false), diff --git a/tests/WxAgent.Core.Tests/UiDiagnosticsTests.cs b/tests/WxAgent.Core.Tests/UiDiagnosticsTests.cs new file mode 100644 index 0000000..3cedfc5 --- /dev/null +++ b/tests/WxAgent.Core.Tests/UiDiagnosticsTests.cs @@ -0,0 +1,47 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class UiDiagnosticsTests +{ + [Fact] + public void TraversalGuard_StopsCyclesBeforeRecursing() + { + var guard = new UiTraversalGuard(); + Assert.True(guard.TryVisit("root", 0)); + Assert.True(guard.TryVisit("child", 1)); + Assert.False(guard.TryVisit("root", 2)); + Assert.True(guard.CycleDetected); + Assert.Equal(2, guard.NodeCount); + } + + [Fact] + public void TraversalGuard_BoundsDepthAndNodesEvenWithMissingRuntimeIds() + { + var depth = new UiTraversalGuard(maxDepth: 2); + Assert.True(depth.TryVisit("", 0)); + Assert.False(depth.TryVisit("", 3)); + Assert.True(depth.Truncated); + var count = new UiTraversalGuard(maxNodes: 2); + Assert.True(count.TryVisit("", 0)); + Assert.True(count.TryVisit("", 1)); + Assert.False(count.TryVisit("", 2)); + Assert.Equal(2, count.NodeCount); + } + + [Fact] + public void SnapshotComparison_DetectsRequiredControlRegressionWithoutLeakingDynamicIds() + { + var baseline = Root(WechatLocators.RequiredAutomationIds); + var missing = WechatLocators.MessageList; + var current = Root(WechatLocators.RequiredAutomationIds.Where(id => id != missing).Append("[id:private-hash]").Append("new_static_control")); + var report = UiSnapshotComparison.Compare(baseline, current); + Assert.Equal([missing], report.MissingRequiredControls); + Assert.Equal([missing], report.RemovedAutomationIds); + Assert.Equal(["new_static_control"], report.AddedAutomationIds); + } + + private static UiNodeSnapshot Root(IEnumerable ids) => + new("Window", "", "", true, "0,0,800,600", ids.Select(id => new UiNodeSnapshot("Pane", "", id, true, "", [])).ToArray()); +} diff --git a/tests/WxAgent.Core.Tests/WechatStabilityTests.cs b/tests/WxAgent.Core.Tests/WechatStabilityTests.cs new file mode 100644 index 0000000..71a5c1a --- /dev/null +++ b/tests/WxAgent.Core.Tests/WechatStabilityTests.cs @@ -0,0 +1,117 @@ +using WxAgent.Core; +using Xunit; + +namespace WxAgent.Core.Tests; + +public sealed class WechatStabilityTests +{ + [Fact] + public void Deduper_HandlesTenThousandMessagesAndEvictsOldEntries() + { + var deduper = new BoundedMessageDeduper(2048); + for (var index = 0; index < 10_000; index++) + Assert.True(deduper.TryAdd($"message-{index}")); + + Assert.False(deduper.TryAdd("message-9999")); + Assert.True(deduper.TryAdd("message-0")); + } + + [Theory] + [InlineData("Qt51514QWindowIcon", "微信", true)] + [InlineData("Qt6QWindowIcon", "微信", true)] + [InlineData("Qt51514QWindowIcon", "文件传输助手", false)] + [InlineData("Qt51514WxTrayIconMessageWindowClass", "微信", false)] + [InlineData("Notepad", "微信", false)] + public void NativeWindowMatching_RejectsSubwindowsAndOtherApplications(string className, string title, bool expected) + { + Assert.Equal(expected, WechatLocators.IsNativeMainWindow(className, title)); + } + + [Theory] + [InlineData("微信", true)] + [InlineData("微信\n收到新消息", true)] + [InlineData("WeChat", true)] + [InlineData("企业微信", false)] + [InlineData("微信测试脚本", false)] + public void TrayMatching_IsExplicit(string name, bool expected) => + Assert.Equal(expected, WechatLocators.IsWechatTrayName(name)); + + [Fact] + public void StabilityAnalyzer_DetectsAgentLeakAndFinalError() + { + var now = DateTimeOffset.UtcNow; + var first = Sample(now, 100, 10, 1); + var last = first with { Timestamp = now.AddHours(1), AgentHandleCount = 101, Errors = [WxAgentErrorCode.SessionLocked] }; + var report = WechatStabilityAnalyzer.Analyze(now, last.Timestamp, [first, last], 0, 0); + Assert.False(report.Healthy); + Assert.Contains("agent-handle-growth-over-100", report.Findings); + Assert.Contains("final-error:SessionLocked", report.Findings); + } + + [Fact] + public void Deduper_RejectsInvalidCapacity() + { + Assert.Throws(() => new BoundedMessageDeduper(0)); + } + + [Fact] + public void StabilityAnalyzer_ReportsHealthyBoundedGrowth() + { + var started = DateTimeOffset.UtcNow; + var samples = new[] + { + Sample(started, 100_000_000, 100, 20), + Sample(started.AddMinutes(1), 110_000_000, 120, 22) + }; + + var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(1), samples, 5, 1); + + Assert.True(report.Healthy); + Assert.Equal(10_000_000, report.WorkingSetGrowthBytes); + Assert.Empty(report.Findings); + } + + [Fact] + public void StabilityAnalyzer_AllowsTransientWindowLossAfterReconnect() + { + var started = DateTimeOffset.UtcNow; + var samples = new[] + { + Sample(started, 100_000_000, 100, 20), + Sample(started.AddMinutes(1), 0, 0, 0, windowFound: false), + Sample(started.AddMinutes(2), 105_000_000, 110, 21) + }; + + var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(2), samples, 0, 1); + + Assert.True(report.Healthy); + Assert.DoesNotContain("wechat-window-unavailable", report.Findings); + } + + [Fact] + public void StabilityAnalyzer_FlagsWindowLossAndResourceGrowth() + { + var started = DateTimeOffset.UtcNow; + var samples = new[] + { + Sample(started, 100_000_000, 100, 20), + Sample(started.AddHours(1), 400_000_000, 700, 150, windowFound: false) + }; + + var report = WechatStabilityAnalyzer.Analyze(started, started.AddHours(1), samples, 0, 0); + + Assert.False(report.Healthy); + Assert.Contains("wechat-window-unavailable", report.Findings); + Assert.Contains("working-set-growth-over-256mb", report.Findings); + Assert.Contains("handle-growth-over-500", report.Findings); + Assert.Contains("thread-growth-over-100", report.Findings); + } + + private static WechatStabilitySample Sample( + DateTimeOffset timestamp, + long memory, + int handles, + int threads, + bool windowFound = true) => + new(timestamp, 1, memory, handles, threads, windowFound, [], 0, 0); +}