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);
+}