190 lines
7.6 KiB
C#
190 lines
7.6 KiB
C#
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);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(30, false, 0, true)]
|
|
[InlineData(15, false, 0, false)]
|
|
[InlineData(30, true, 0, false)]
|
|
[InlineData(30, false, 11, false)]
|
|
public void RunChecks_ApplyDurationCancellationAndHeartbeatToEveryReport(int seconds, bool cancelled, int snapshotAge, bool healthy)
|
|
{
|
|
var finish = DateTimeOffset.UtcNow;
|
|
var last = Sample(finish, 100, 10, 1) with { LastSnapshotAt = finish.AddSeconds(-snapshotAge) };
|
|
var report = WechatStabilityAnalyzer.Analyze(finish.AddSeconds(-seconds), finish, [last], 0, 0);
|
|
var checkedReport = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(seconds), cancelled);
|
|
Assert.Equal(healthy, checkedReport.Healthy);
|
|
Assert.Equal(seconds >= 29 && !cancelled, checkedReport.CompletedDuration);
|
|
}
|
|
|
|
[Fact]
|
|
public void RunChecks_RejectMissingHeartbeatAndBackgroundFailures()
|
|
{
|
|
var finish = DateTimeOffset.UtcNow;
|
|
var report = WechatStabilityAnalyzer.Analyze(finish.AddHours(-8), finish, [Sample(finish, 0, 0, 0)], 0, 0);
|
|
report = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromHours(8), TimeSpan.FromHours(8), false,
|
|
"InvalidOperationException", "IOException");
|
|
Assert.False(report.Healthy);
|
|
Assert.Contains("listener-snapshot-stale", report.Findings);
|
|
Assert.Contains("listener-failed:InvalidOperationException", report.Findings);
|
|
Assert.Contains("sampling-failed:IOException", report.Findings);
|
|
}
|
|
|
|
[Fact]
|
|
public void StabilityAnalyzer_DoesNotHideMissingHeartbeatBeforeRecovery()
|
|
{
|
|
var started = DateTimeOffset.UtcNow;
|
|
var missing = Sample(started.AddMinutes(6), 100, 10, 1);
|
|
var recovered = missing with { Timestamp = started.AddMinutes(7), LastSnapshotAt = started.AddMinutes(7) };
|
|
var report = WechatStabilityAnalyzer.Analyze(started, recovered.Timestamp, [missing, recovered], 0, 1);
|
|
Assert.Contains("listener-snapshot-gap-over-5min", report.Findings);
|
|
Assert.False(report.Healthy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReportWrite_PreservesExistingEvidenceWhenCancelled()
|
|
{
|
|
var directory = Path.Combine(Path.GetTempPath(), "wx-stability-" + Guid.NewGuid());
|
|
Directory.CreateDirectory(directory);
|
|
try
|
|
{
|
|
var path = Path.Combine(directory, "report.json");
|
|
await File.WriteAllTextAsync(path, "previous evidence");
|
|
var now = DateTimeOffset.UtcNow;
|
|
var report = WechatStabilityAnalyzer.Analyze(now, now, [Sample(now, 0, 0, 0)], 0, 0);
|
|
using var cancelled = new CancellationTokenSource();
|
|
cancelled.Cancel();
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
|
WechatStabilityAnalyzer.WriteJsonAsync(report, path, cancelled.Token));
|
|
Assert.Equal("previous evidence", await File.ReadAllTextAsync(path));
|
|
Assert.False(File.Exists(path + ".tmp"));
|
|
}
|
|
finally { Directory.Delete(directory, recursive: true); }
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Shell_TrayWnd", true)]
|
|
[InlineData("Shell_SecondaryTrayWnd", true)]
|
|
[InlineData("NotifyIconOverflowWindow", true)]
|
|
[InlineData("Qt51514QWindowIcon", false)]
|
|
[InlineData("Chrome_WidgetWin_1", false)]
|
|
[InlineData("", false)]
|
|
public void TrayClicks_RequireANotificationAreaHitTarget(string className, bool expected)
|
|
{
|
|
Assert.Equal(expected, WechatLocators.IsNotificationAreaRoot(className));
|
|
}
|
|
|
|
private static WechatStabilitySample Sample(
|
|
DateTimeOffset timestamp,
|
|
long memory,
|
|
int handles,
|
|
int threads,
|
|
bool windowFound = true) =>
|
|
new(timestamp, 1, memory, handles, threads, windowFound, [], 0, 0);
|
|
}
|