Compare commits

...

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
d72c5510ec
test: add 22 new tests for WindowsNodeClient and OpenClawGatewayClient coverage
Agent-Logs-Url: https://github.com/openclaw/openclaw-windows-node/sessions/d2e01bd9-cb98-4298-9d46-adc8f56bb2d8

Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
2026-04-01 20:59:38 +00:00
2 changed files with 583 additions and 0 deletions

View File

@ -805,4 +805,192 @@ public class OpenClawGatewayClientTests
Assert.Single(channels);
Assert.Equal("degraded", channels[0].Status);
}
[Fact]
public void ClassifyTool_UnknownTool_DefaultsToToolKind()
{
var helper = new GatewayClientTestHelper();
// Tools not explicitly mapped fall back to the Tool activity kind
Assert.Equal(ActivityKind.Tool, helper.ClassifyTool("job"));
Assert.Equal(ActivityKind.Tool, helper.ClassifyTool("grep"));
Assert.Equal(ActivityKind.Tool, helper.ClassifyTool("search"));
Assert.Equal(ActivityKind.Tool, helper.ClassifyTool("completely_unknown_tool_xyz"));
}
[Fact]
public void GetSessionList_EmptyBeforeAnyPayload_ReturnsEmptyArray()
{
var helper = new GatewayClientTestHelper();
var sessions = helper.GetSessionList();
Assert.Empty(sessions);
}
[Fact]
public void ProcessRawMessage_InvalidJson_IsIgnoredGracefully()
{
var helper = new GatewayClientTestHelper();
// Should not throw
helper.ProcessRawMessage("not valid json {{{");
}
[Fact]
public void ParseUsageStatus_WithMultipleProviders_ListsAllInSummary()
{
var helper = new GatewayClientTestHelper();
var usage = helper.ParseUsageStatusPayload("""
{
"updatedAt": 1739760000000,
"providers": [
{
"provider": "openai",
"displayName": "OpenAI",
"windows": [
{ "label": "daily", "usedPercent": 10.0 }
]
},
{
"provider": "anthropic",
"displayName": "Anthropic",
"windows": [
{ "label": "daily", "usedPercent": 40.0 }
]
}
]
}
""");
Assert.NotNull(usage.ProviderSummary);
Assert.Contains("OpenAI", usage.ProviderSummary!);
Assert.Contains("Anthropic", usage.ProviderSummary!);
}
[Fact]
public void ParseUsageStatus_WithNoProviders_SetsSummaryOrEmpty()
{
var helper = new GatewayClientTestHelper();
var usage = helper.ParseUsageStatusPayload("""
{
"updatedAt": 1739760000000,
"providers": []
}
""");
// ProviderSummary may be null or empty when there are no providers
Assert.True(usage.ProviderSummary == null || usage.ProviderSummary.Length == 0);
}
[Fact]
public void ParseUsageCost_WithZeroTotals_UsageDisplayTextShowsNoData()
{
var helper = new GatewayClientTestHelper();
var usage = helper.ParseUsageCostPayload("""
{
"updatedAt": 1739760000000,
"days": 30,
"totals": {
"totalTokens": 0,
"totalCost": 0.0
}
}
""");
Assert.Equal(0, usage.TotalTokens);
Assert.Equal(0.0, usage.CostUsd, 3);
Assert.Equal("No usage data", usage.DisplayText);
}
[Fact]
public void ParseNodeList_WithEmptyNodesArray_ReturnsEmptyArray()
{
var helper = new GatewayClientTestHelper();
var nodes = helper.ParseNodeListPayload("""{"nodes":[]}""");
Assert.Empty(nodes);
}
[Fact]
public void ParseChannelHealth_WithLinkedAndAuthAge_PopulatesIsLinkedAndAuthAge()
{
var helper = new GatewayClientTestHelper();
var json = """
{
"telegram": {
"status": "ready",
"linked": true,
"authAge": "3d ago"
}
}
""";
var (channels, fired) = helper.ParseChannelHealthPayload(json);
Assert.True(fired);
Assert.Single(channels);
var ch = channels[0];
Assert.Equal("telegram", ch.Name);
Assert.Equal("ready", ch.Status);
Assert.True(ch.IsLinked);
Assert.Equal("3d ago", ch.AuthAge);
}
[Fact]
public void ParseChannelHealth_WithErrorField_PopulatesError()
{
var helper = new GatewayClientTestHelper();
var json = """
{
"slack": {
"status": "error",
"error": "OAuth token expired"
}
}
""";
var (channels, _) = helper.ParseChannelHealthPayload(json);
Assert.Single(channels);
Assert.Equal("error", channels[0].Status);
Assert.Equal("OAuth token expired", channels[0].Error);
}
[Fact]
public void ParseSessionsPreviewPayload_EmptyPreviews_ReturnsEmptyList()
{
var helper = new GatewayClientTestHelper();
var result = helper.ParseSessionsPreviewPayload("""
{
"ts": 1739760000000,
"previews": []
}
""");
Assert.Empty(result.Previews);
}
[Fact]
public void ParseNodeList_OnlineVsOfflineStatus_DerivedFromStatusField()
{
var helper = new GatewayClientTestHelper();
var nodes = helper.ParseNodeListPayload("""
{
"nodes": [
{
"nodeId": "online-node",
"displayName": "PC",
"status": "connected"
},
{
"nodeId": "offline-node",
"displayName": "Laptop",
"status": "disconnected"
}
]
}
""");
Assert.Equal(2, nodes.Length);
var onlineNode = nodes.First(n => n.NodeId == "online-node");
var offlineNode = nodes.First(n => n.NodeId == "offline-node");
Assert.True(onlineNode.IsOnline);
Assert.False(offlineNode.IsOnline);
}
}

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
using OpenClaw.Shared;
using Xunit;
@ -10,6 +11,52 @@ namespace OpenClaw.Shared.Tests;
public class WindowsNodeClientTests
{
// ── helpers ────────────────────────────────────────────────────────────────
private static string CreateTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
return dir;
}
private static void DeleteTempDir(string dir)
{
if (Directory.Exists(dir)) Directory.Delete(dir, true);
}
/// <summary>
/// Invokes the protected ProcessMessageAsync method via reflection and awaits the returned task.
/// Simulates a raw message arriving from the gateway without requiring a live WebSocket.
/// </summary>
private static async Task InvokeProcessMessageAsync(WindowsNodeClient client, string json)
{
var method = typeof(WindowsNodeClient).GetMethod(
"ProcessMessageAsync",
BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(method);
var task = (Task)method!.Invoke(client, [json])!;
await task;
}
/// <summary>A minimal capability that handles "test.cmd" for dispatch tests.</summary>
private sealed class StubCapability : NodeCapabilityBase
{
public override string Category => "test";
private static readonly string[] _cmds = { "test.cmd" };
public override System.Collections.Generic.IReadOnlyList<string> Commands => _cmds;
public bool WasInvoked { get; private set; }
public StubCapability() : base(NullLogger.Instance) { }
public override Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest request)
{
WasInvoked = true;
return Task.FromResult(Success(new { ok = true }));
}
}
[Theory]
[InlineData("http://localhost:18789", "ws://localhost:18789")]
[InlineData("https://host.tailnet.ts.net", "wss://host.tailnet.ts.net")]
@ -142,4 +189,352 @@ public class WindowsNodeClientTests
}
}
}
/// <summary>
/// When hello-ok arrives and the device already has a stored token (was previously paired),
/// PairingStatusChanged fires exactly once with Paired status and a null message.
/// </summary>
[Fact]
public void HandleResponse_HelloOkWithAlreadyStoredToken_FiresPairedWithNullMessage()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
// Simulate the device having been approved before by storing a device token
var deviceIdentityField = typeof(WindowsNodeClient).GetField(
"_deviceIdentity", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(deviceIdentityField);
var deviceIdentity = (DeviceIdentity)deviceIdentityField!.GetValue(client)!;
deviceIdentity.StoreDeviceToken("previously-stored-device-token");
var pairingEvents = new List<PairingStatusEventArgs>();
client.PairingStatusChanged += (_, e) => pairingEvents.Add(e);
var json = """
{
"type": "res",
"ok": true,
"payload": {
"type": "hello-ok",
"nodeId": "test-node-id"
}
}
""";
var root = JsonDocument.Parse(json).RootElement;
var handleResponseMethod = typeof(WindowsNodeClient).GetMethod(
"HandleResponse", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(handleResponseMethod);
handleResponseMethod!.Invoke(client, [root]);
Assert.Single(pairingEvents);
Assert.Equal(PairingStatus.Paired, pairingEvents[0].Status);
Assert.Null(pairingEvents[0].Message); // No message when already paired
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>
/// When a response with ok=false arrives, StatusChanged fires with Error status.
/// </summary>
[Fact]
public void HandleResponse_ErrorResponseOkFalse_RaisesErrorStatus()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
var statusChanges = new List<ConnectionStatus>();
client.StatusChanged += (_, s) => statusChanges.Add(s);
// A response with payload present but ok=false triggers the error path
var json = """
{
"type": "res",
"ok": false,
"payload": {},
"error": {
"message": "Invalid token",
"code": "auth.invalid"
}
}
""";
var root = JsonDocument.Parse(json).RootElement;
var handleResponseMethod = typeof(WindowsNodeClient).GetMethod(
"HandleResponse", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(handleResponseMethod);
handleResponseMethod!.Invoke(client, [root]);
Assert.Contains(ConnectionStatus.Error, statusChanges);
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>
/// RegisterCapability adds the capability's category and all its commands to the
/// internal registration object, making them available for advertisement to the gateway.
/// </summary>
[Fact]
public void RegisterCapability_AddsCategoryAndCommandsToRegistration()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
var capability = new StubCapability();
client.RegisterCapability(capability);
Assert.Contains(capability, client.Capabilities);
var regField = typeof(WindowsNodeClient).GetField(
"_registration", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(regField);
var reg = (NodeRegistration)regField!.GetValue(client)!;
Assert.Contains("test", reg.Capabilities);
Assert.Contains("test.cmd", reg.Commands);
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>
/// Registering the same capability twice does not duplicate its category or commands
/// in the registration object.
/// </summary>
[Fact]
public void RegisterCapability_DoesNotDuplicateCategoryOrCommands_WhenAddedTwice()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
var capability = new StubCapability();
client.RegisterCapability(capability);
client.RegisterCapability(capability);
var regField = typeof(WindowsNodeClient).GetField(
"_registration", BindingFlags.NonPublic | BindingFlags.Instance);
var reg = (NodeRegistration)regField!.GetValue(client)!;
Assert.Equal(1, reg.Capabilities.Count(c => c == "test"));
Assert.Equal(1, reg.Commands.Count(c => c == "test.cmd"));
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>SetPermission stores the permission value in the node registration.</summary>
[Fact]
public void SetPermission_StoresPermissionValue()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
client.SetPermission("system.run", true);
client.SetPermission("camera", false);
var regField = typeof(WindowsNodeClient).GetField(
"_registration", BindingFlags.NonPublic | BindingFlags.Instance);
var reg = (NodeRegistration)regField!.GetValue(client)!;
Assert.True(reg.Permissions["system.run"]);
Assert.False(reg.Permissions["camera"]);
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>Invalid JSON is silently ignored — no exception propagates to the caller.</summary>
[Fact]
public async Task ProcessMessageAsync_InvalidJson_IsIgnoredGracefully()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
// Should not throw
await InvokeProcessMessageAsync(client, "this is not valid json {{{");
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>A message with an unrecognised 'type' field is silently ignored.</summary>
[Fact]
public async Task ProcessMessageAsync_UnknownMessageType_IsIgnoredGracefully()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
await InvokeProcessMessageAsync(client, """{"type":"xyzzy","payload":{}}""");
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>
/// A node.invoke request for a command that has no registered capability handler does
/// not throw; the client logs a warning and attempts to send an error response (which
/// silently drops when there is no live WebSocket).
/// </summary>
[Fact]
public async Task ProcessMessageAsync_ReqNodeInvoke_UnregisteredCommand_IsHandledWithoutException()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
// No capabilities registered
var json = """
{
"type": "req",
"id": "req-1",
"method": "node.invoke",
"params": {
"command": "unknown.command",
"args": {}
}
}
""";
// Should not throw
await InvokeProcessMessageAsync(client, json);
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>
/// A node.invoke request whose command contains invalid characters (spaces, semicolons)
/// is rejected gracefully without throwing.
/// </summary>
[Fact]
public async Task ProcessMessageAsync_ReqNodeInvoke_InvalidCommandFormat_IsHandledWithoutException()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
var json = """
{
"type": "req",
"id": "req-2",
"method": "node.invoke",
"params": {
"command": "bad command; rm -rf /",
"args": {}
}
}
""";
await InvokeProcessMessageAsync(client, json);
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>
/// A node.invoke request for a registered capability dispatches to that capability
/// and fires the InvokeReceived event exactly once.
/// </summary>
[Fact]
public async Task ProcessMessageAsync_ReqNodeInvoke_ValidCommand_DispatchesToCapabilityAndFiresEvent()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
var stub = new StubCapability();
client.RegisterCapability(stub);
var invokeEvents = new List<NodeInvokeRequest>();
client.InvokeReceived += (_, req) => invokeEvents.Add(req);
var json = """
{
"type": "req",
"id": "req-3",
"method": "node.invoke",
"params": {
"command": "test.cmd",
"args": { "foo": "bar" }
}
}
""";
await InvokeProcessMessageAsync(client, json);
Assert.True(stub.WasInvoked);
Assert.Single(invokeEvents);
Assert.Equal("test.cmd", invokeEvents[0].Command);
}
finally
{
DeleteTempDir(dataPath);
}
}
/// <summary>
/// A node.invoke.request event (different from req type) with no command is handled
/// gracefully — no exception propagates.
/// </summary>
[Fact]
public async Task ProcessMessageAsync_EventNodeInvokeRequest_MissingCommand_IsHandledWithoutException()
{
var dataPath = CreateTempDir();
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "test-token", dataPath);
var json = """
{
"type": "event",
"event": "node.invoke.request",
"payload": {
"requestId": "evt-1"
}
}
""";
await InvokeProcessMessageAsync(client, json);
}
finally
{
DeleteTempDir(dataPath);
}
}
}