Compare commits
1 Commits
master
...
copilot/an
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72c5510ec |
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user