fix: restore existing WeChat instance and add M6 validation tools
This commit is contained in:
@@ -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 <dir> --baseline <ui-tree.json>`:脱敏快照、操作系统/微信版本、会话、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 自动重发测试消息;后续任务仅手动触发,不配置未来重复触发器。
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Sent": true,
|
||||
"MatchingEvents": 1,
|
||||
"EventsAfterRestart": 0,
|
||||
"Snapshots": 5,
|
||||
"CheckpointSaved": true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<string> _order = new();
|
||||
private readonly HashSet<string> _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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed class UiTraversalGuard(int maxNodes = 10_000, int maxDepth = 40)
|
||||
{
|
||||
private readonly HashSet<string> _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<string> AddedAutomationIds,
|
||||
IReadOnlyList<string> RemovedAutomationIds, IReadOnlyList<string> MissingRequiredControls)
|
||||
{
|
||||
public static UiSnapshotComparison Compare(UiNodeSnapshot baseline, UiNodeSnapshot current)
|
||||
{
|
||||
static IEnumerable<string> ReadIds(UiNodeSnapshot root) =>
|
||||
new[] { root.AutomationId }.Concat(root.Children.SelectMany(ReadIds));
|
||||
static HashSet<string> 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());
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<WxAgentErrorCode> 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<WechatStabilitySample> Samples,
|
||||
long MessageEvents,
|
||||
long ReconnectEvents,
|
||||
long WorkingSetGrowthBytes,
|
||||
int HandleGrowth,
|
||||
int ThreadGrowth,
|
||||
bool Healthy,
|
||||
IReadOnlyList<string> Findings,
|
||||
double RequestedSeconds = 0,
|
||||
double ObservedSeconds = 0,
|
||||
bool CompletedDuration = false);
|
||||
|
||||
public static class WechatStabilityAnalyzer
|
||||
{
|
||||
public static WechatStabilityReport Analyze(
|
||||
DateTimeOffset startedAt,
|
||||
DateTimeOffset finishedAt,
|
||||
IReadOnlyList<WechatStabilitySample> 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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<UiNodeSnapshot>(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 <dir> [--baseline <ui-tree.json>] [--timeout 60]
|
||||
inspect-ui --output <path> [--timeout 30]
|
||||
smoke [--output <path>] [--timeout 30]
|
||||
m4-smoke [--timeout 60]
|
||||
m5-smoke [--timeout 60]
|
||||
tray-status [--timeout 30]
|
||||
window-status [--timeout 30]
|
||||
listener-smoke --output <new-dir> [--timeout 60]
|
||||
recovery-smoke [--timeout 30]
|
||||
recover-ui [--timeout 30]
|
||||
stability-smoke [--seconds 15] [--sample-seconds 5] [--output <dir>]
|
||||
endurance [--hours 24] [--sample-seconds 60] [--output <dir>]
|
||||
group at-all --group <name> --message <text> --confirm CONFIRM [--timeout 30]
|
||||
group verify-at-all --group <name> --message <text> [--timeout 30]
|
||||
chat send-url-card --to <name> --url <http-url> [--message <text>] --confirm CONFIRM [--timeout 60]
|
||||
|
||||
@@ -3,3 +3,14 @@ CloseHandle
|
||||
VirtualQueryEx
|
||||
ReadProcessMemory
|
||||
QueryFullProcessImageName
|
||||
EnumWindows
|
||||
GetWindowThreadProcessId
|
||||
GetWindowText
|
||||
GetClassName
|
||||
IsWindowVisible
|
||||
IsIconic
|
||||
GetDpiForWindow
|
||||
GetWindowRect
|
||||
AccessibleObjectFromWindow
|
||||
AccessibleChildren
|
||||
EnumChildWindows
|
||||
|
||||
@@ -416,7 +416,8 @@ public static partial class WechatChatClient
|
||||
TimeSpan duration,
|
||||
string? checkpointPath,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken,
|
||||
IReadOnlyList<Func<MessageEvent, CancellationToken, Task>>? callbacks = null)
|
||||
IReadOnlyList<Func<MessageEvent, CancellationToken, Task>>? callbacks = null,
|
||||
Action? snapshotObserved = null)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
@@ -448,33 +449,31 @@ public static partial class WechatChatClient
|
||||
IReadOnlyList<ChatMessageSnapshot>? 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;
|
||||
}
|
||||
|
||||
@@ -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<WxAgentErrorCode>();
|
||||
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<ProcessDiagnostic>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WechatListenerSmokeResult> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<WechatWindowDiagnostic> Enumerate()
|
||||
{
|
||||
using var current = Process.GetCurrentProcess();
|
||||
var processIds = new HashSet<int>();
|
||||
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<WechatWindowDiagnostic>();
|
||||
PInvoke.EnumWindows((hwnd, _) =>
|
||||
{
|
||||
uint pid;
|
||||
PInvoke.GetWindowThreadProcessId(hwnd, &pid);
|
||||
if (!processIds.Contains((int)pid)) return true;
|
||||
Span<char> className = stackalloc char[256];
|
||||
Span<char> 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<WechatWindowDiagnostic> InspectNativeWindows() =>
|
||||
WechatNativeWindow.Enumerate().Select(WechatNativeWindow.Sanitize).ToArray();
|
||||
}
|
||||
@@ -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<IReadOnlyList<WechatRecoveryCheck>> RecoverySmokeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var checks = new List<WechatRecoveryCheck>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<WechatStabilityReport> 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<int>();
|
||||
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<WechatStabilitySample>();
|
||||
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<WechatOperationResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<HWND>();
|
||||
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<IAccessible>();
|
||||
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<char> 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);
|
||||
}
|
||||
@@ -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<UiNodeSnapshot>();
|
||||
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),
|
||||
|
||||
@@ -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<string> ids) =>
|
||||
new("Window", "", "", true, "0,0,800,600", ids.Select(id => new UiNodeSnapshot("Pane", "", id, true, "", [])).ToArray());
|
||||
}
|
||||
@@ -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<ArgumentOutOfRangeException>(() => 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);
|
||||
}
|
||||
Reference in New Issue
Block a user