7 fixes from Opus/Codex/Gemini triple-model review with cross-validation: Consensus (2+ models agreed): - ExecApprovalPolicy bypass: full argv evaluated, not just argv[0] - Thread safety: lock pattern for _sessions/_nodes dictionaries - SendRawAsync/Dispose races: defensive patterns from WindowsNodeClient Cross-validated (different model confirmed): - Canvas jsonlPath restricted to temp directory (arbitrary file read) - ChannelHealth 'active' status shows [ON] not [OFF] - OnSettingsSaved mirrors startup if/else (no dual connections) - TryEnqueue checked in OnCanvasEval/OnCanvasSnapshot (prevents hang) 5 new security tests. All 576 tests pass.
934 lines
30 KiB
C#
934 lines
30 KiB
C#
using System.IO;
|
|
using System.Text.Json;
|
|
using Xunit;
|
|
using OpenClaw.Shared;
|
|
using OpenClaw.Shared.Capabilities;
|
|
|
|
namespace OpenClaw.Shared.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for each capability: SystemCapability, CanvasCapability,
|
|
/// ScreenCapability, CameraCapability.
|
|
/// Tests execute logic, arg parsing, event raising, and error paths.
|
|
/// No hardware or UI dependencies.
|
|
/// </summary>
|
|
public class SystemCapabilityTests
|
|
{
|
|
private static JsonElement Parse(string json)
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
return doc.RootElement.Clone();
|
|
}
|
|
|
|
[Fact]
|
|
public void CanHandle_SystemNotify()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
Assert.True(cap.CanHandle("system.notify"));
|
|
Assert.True(cap.CanHandle("system.run"));
|
|
Assert.False(cap.CanHandle("system.unknown"));
|
|
Assert.Equal("system", cap.Category);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Notify_RaisesEvent_WithArgs()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
SystemNotifyArgs? received = null;
|
|
cap.NotifyRequested += (s, a) => received = a;
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "n1",
|
|
Command = "system.notify",
|
|
Args = Parse("""{"title":"Hello","body":"World","sound":false}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.NotNull(received);
|
|
Assert.Equal("Hello", received!.Title);
|
|
Assert.Equal("World", received.Body);
|
|
Assert.False(received.PlaySound);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Notify_DefaultsTitle_WhenMissing()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
SystemNotifyArgs? received = null;
|
|
cap.NotifyRequested += (s, a) => received = a;
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "n2",
|
|
Command = "system.notify",
|
|
Args = Parse("""{"body":"Just body"}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.Equal("OpenClaw", received!.Title);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnknownCommand_ReturnsError()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "n3",
|
|
Command = "system.unknown",
|
|
Args = Parse("""{}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("Unknown command", res.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_AcceptsCommandAsArray()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
var runner = new FakeCommandRunner();
|
|
cap.SetCommandRunner(runner);
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "r1",
|
|
Command = "system.run",
|
|
Args = Parse("""{"command":["echo","hello","world"]}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.Equal("echo", runner.LastRequest!.Command);
|
|
Assert.Equal(new[] { "hello", "world" }, runner.LastRequest.Args);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_AcceptsCommandAsString()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
var runner = new FakeCommandRunner();
|
|
cap.SetCommandRunner(runner);
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "r2",
|
|
Command = "system.run",
|
|
Args = Parse("""{"command":"hostname"}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.Equal("hostname", runner.LastRequest!.Command);
|
|
Assert.Null(runner.LastRequest.Args);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_AcceptsSingleElementArray()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
var runner = new FakeCommandRunner();
|
|
cap.SetCommandRunner(runner);
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "r3",
|
|
Command = "system.run",
|
|
Args = Parse("""{"command":["hostname"]}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.Equal("hostname", runner.LastRequest!.Command);
|
|
Assert.Null(runner.LastRequest.Args);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_ReturnsError_WhenNoCommand()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
cap.SetCommandRunner(new FakeCommandRunner());
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "r4",
|
|
Command = "system.run",
|
|
Args = Parse("""{}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("Missing command", res.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_ReturnsError_WhenNoRunner()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "r5",
|
|
Command = "system.run",
|
|
Args = Parse("""{"command":["echo","test"]}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("not available", res.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_ReadsTimeoutMs()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
var runner = new FakeCommandRunner();
|
|
cap.SetCommandRunner(runner);
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "r6",
|
|
Command = "system.run",
|
|
Args = Parse("""{"command":["test"],"timeoutMs":60000}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.Equal(60000, runner.LastRequest!.TimeoutMs);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanHandle_SystemWhich()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
Assert.True(cap.CanHandle("system.which"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Which_FindsKnownBins()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
// cmd.exe should always exist on Windows
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "w1",
|
|
Command = "system.which",
|
|
Args = Parse("""{"bins":["cmd"]}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
|
|
var payload = JsonSerializer.Deserialize<JsonElement>(
|
|
JsonSerializer.Serialize(res.Payload));
|
|
Assert.True(payload.TryGetProperty("bins", out var binsEl));
|
|
// cmd should resolve on Windows
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
Assert.True(binsEl.TryGetProperty("cmd", out var cmdPath));
|
|
Assert.Contains("cmd", cmdPath.GetString()!, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Which_OmitsMissingBins()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "w2",
|
|
Command = "system.which",
|
|
Args = Parse("""{"bins":["totally_nonexistent_binary_xyz123"]}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
|
|
var payload = JsonSerializer.Deserialize<JsonElement>(
|
|
JsonSerializer.Serialize(res.Payload));
|
|
Assert.True(payload.TryGetProperty("bins", out var binsEl));
|
|
Assert.False(binsEl.TryGetProperty("totally_nonexistent_binary_xyz123", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Which_RejectsPathsWithSeparators()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "w3",
|
|
Command = "system.which",
|
|
Args = Parse("""{"bins":["..\\..\\etc\\passwd","../../../bin/sh"]}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
|
|
var payload = JsonSerializer.Deserialize<JsonElement>(
|
|
JsonSerializer.Serialize(res.Payload));
|
|
Assert.True(payload.TryGetProperty("bins", out var binsEl));
|
|
// Both should be rejected (contain path separators)
|
|
Assert.Equal(0, binsEl.EnumerateObject().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Which_ReturnsErrorWhenNoBins()
|
|
{
|
|
var cap = new SystemCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "w4",
|
|
Command = "system.which",
|
|
Args = Parse("""{"bins":[]}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveExecutable_FindsCmdOnWindows()
|
|
{
|
|
if (!OperatingSystem.IsWindows()) return;
|
|
var path = SystemCapability.ResolveExecutable("cmd");
|
|
Assert.NotNull(path);
|
|
Assert.EndsWith(".exe", path, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveExecutable_RejectsPathTraversal()
|
|
{
|
|
Assert.Null(SystemCapability.ResolveExecutable("..\\cmd"));
|
|
Assert.Null(SystemCapability.ResolveExecutable("../bin/sh"));
|
|
Assert.Null(SystemCapability.ResolveExecutable("C:\\Windows\\cmd"));
|
|
}
|
|
|
|
private class FakeCommandRunner : ICommandRunner
|
|
{
|
|
public string Name => "fake";
|
|
public CommandRequest? LastRequest { get; private set; }
|
|
|
|
public Task<CommandResult> RunAsync(CommandRequest request, CancellationToken ct = default)
|
|
{
|
|
LastRequest = request;
|
|
return Task.FromResult(new CommandResult
|
|
{
|
|
Stdout = "ok",
|
|
Stderr = "",
|
|
ExitCode = 0,
|
|
TimedOut = false,
|
|
DurationMs = 1
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
public class CanvasCapabilityTests
|
|
{
|
|
private static JsonElement Parse(string json)
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
return doc.RootElement.Clone();
|
|
}
|
|
|
|
[Fact]
|
|
public void CanHandle_AllCanvasCommands()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
Assert.True(cap.CanHandle("canvas.present"));
|
|
Assert.True(cap.CanHandle("canvas.hide"));
|
|
Assert.True(cap.CanHandle("canvas.navigate"));
|
|
Assert.True(cap.CanHandle("canvas.eval"));
|
|
Assert.True(cap.CanHandle("canvas.snapshot"));
|
|
Assert.True(cap.CanHandle("canvas.a2ui.push"));
|
|
Assert.True(cap.CanHandle("canvas.a2ui.reset"));
|
|
Assert.False(cap.CanHandle("canvas.unknown"));
|
|
Assert.Equal("canvas", cap.Category);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Present_RaisesEvent_WithArgs()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
CanvasPresentArgs? received = null;
|
|
cap.PresentRequested += (s, a) => received = a;
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "c1",
|
|
Command = "canvas.present",
|
|
Args = Parse("""{"url":"https://example.com","width":1024,"height":768,"title":"Test","alwaysOnTop":true}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.NotNull(received);
|
|
Assert.Equal("https://example.com", received!.Url);
|
|
Assert.Equal(1024, received.Width);
|
|
Assert.Equal(768, received.Height);
|
|
Assert.Equal("Test", received.Title);
|
|
Assert.True(received.AlwaysOnTop);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Present_UsesDefaults_WhenArgsMissing()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
CanvasPresentArgs? received = null;
|
|
cap.PresentRequested += (s, a) => received = a;
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "c2",
|
|
Command = "canvas.present",
|
|
Args = Parse("""{}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.Equal(800, received!.Width);
|
|
Assert.Equal(600, received.Height);
|
|
Assert.Equal("Canvas", received.Title);
|
|
Assert.False(received.AlwaysOnTop);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Hide_RaisesEvent()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
bool hideRaised = false;
|
|
cap.HideRequested += (s, e) => hideRaised = true;
|
|
|
|
var req = new NodeInvokeRequest { Id = "c3", Command = "canvas.hide", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.True(hideRaised);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Navigate_ReturnsError_WhenUrlMissing()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest { Id = "c4", Command = "canvas.navigate", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("url", res.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Eval_AcceptsJavaScriptParam()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
string? evaledScript = null;
|
|
cap.EvalRequested += (script) =>
|
|
{
|
|
evaledScript = script;
|
|
return Task.FromResult("42");
|
|
};
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "c5",
|
|
Command = "canvas.eval",
|
|
Args = Parse("""{"javaScript":"document.title"}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.Equal("document.title", evaledScript);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Eval_ReturnsError_WhenNoScript()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest { Id = "c6", Command = "canvas.eval", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("script", res.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Eval_ReturnsError_WhenNoHandler()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "c7",
|
|
Command = "canvas.eval",
|
|
Args = Parse("""{"script":"test"}""")
|
|
};
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("not available", res.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Snapshot_ReturnsError_WhenNoHandler()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest { Id = "c8", Command = "canvas.snapshot", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("not available", res.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A2UIPush_ReturnsError_WhenNoJsonl()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest { Id = "c9", Command = "canvas.a2ui.push", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("jsonl", res.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A2UIPush_RaisesEvent_WithJsonl()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
CanvasA2UIArgs? received = null;
|
|
cap.A2UIPushRequested += (s, a) => received = a;
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "c10",
|
|
Command = "canvas.a2ui.push",
|
|
Args = Parse("""{"jsonl":"{\"type\":\"text\"}"}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.NotNull(received);
|
|
Assert.Contains("text", received!.Jsonl);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A2UIReset_RaisesEvent()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
bool resetRaised = false;
|
|
cap.A2UIResetRequested += (s, e) => resetRaised = true;
|
|
|
|
var req = new NodeInvokeRequest { Id = "c11", Command = "canvas.a2ui.reset", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
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);
|
|
// Use a path within the temp directory so path validation passes
|
|
var missingFile = Path.Combine(Path.GetTempPath(), $"nonexistent-{Guid.NewGuid():N}.jsonl");
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "c17",
|
|
Command = "canvas.a2ui.push",
|
|
Args = Parse($"{{\"jsonlPath\":\"{missingFile.Replace("\\", "\\\\")}\"}}")
|
|
};
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("Failed to read jsonlPath", res.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A2UIPush_WithJsonlPathOutsideTempDir_ReturnsError()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "c18",
|
|
Command = "canvas.a2ui.push",
|
|
Args = Parse("""{"jsonlPath":"C:\\Windows\\System32\\config\\SAM"}""")
|
|
};
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("temp directory", res.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A2UIPush_WithJsonlPathTraversal_ReturnsError()
|
|
{
|
|
var cap = new CanvasCapability(NullLogger.Instance);
|
|
// Path traversal attempt to escape temp directory
|
|
var traversalPath = Path.Combine(Path.GetTempPath(), "..", "..", "Windows", "System32", "config", "SAM");
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "c19",
|
|
Command = "canvas.a2ui.push",
|
|
Args = Parse($"{{\"jsonlPath\":\"{traversalPath.Replace("\\", "\\\\")}\"}}")
|
|
};
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("temp directory", res.Error);
|
|
}
|
|
}
|
|
|
|
public class ScreenCapabilityTests
|
|
{
|
|
private static JsonElement Parse(string json)
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
return doc.RootElement.Clone();
|
|
}
|
|
|
|
[Fact]
|
|
public void CanHandle_ScreenCommands()
|
|
{
|
|
var cap = new ScreenCapability(NullLogger.Instance);
|
|
Assert.True(cap.CanHandle("screen.capture"));
|
|
Assert.True(cap.CanHandle("screen.list"));
|
|
Assert.False(cap.CanHandle("screen.record"));
|
|
Assert.Equal("screen", cap.Category);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Capture_ReturnsError_WhenNoHandler()
|
|
{
|
|
var cap = new ScreenCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest { Id = "s1", Command = "screen.capture", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("not available", res.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Capture_CallsHandler_WithArgs()
|
|
{
|
|
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 = "abc" });
|
|
};
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "s2",
|
|
Command = "screen.capture",
|
|
Args = Parse("""{"format":"jpeg","maxWidth":800,"quality":50,"screenIndex":1}""")
|
|
};
|
|
|
|
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(50, receivedArgs.Quality);
|
|
Assert.Equal(1, receivedArgs.MonitorIndex);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_ReturnsError_WhenNoHandler()
|
|
{
|
|
var cap = new ScreenCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest { Id = "s3", Command = "screen.list", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_ReturnsScreens_WhenHandler()
|
|
{
|
|
var cap = new ScreenCapability(NullLogger.Instance);
|
|
cap.ListRequested += () => Task.FromResult(new[]
|
|
{
|
|
new ScreenInfo { Index = 0, Name = "Main", IsPrimary = true, Width = 2560, Height = 1440 }
|
|
});
|
|
|
|
var req = new NodeInvokeRequest { Id = "s4", Command = "screen.list", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
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
|
|
{
|
|
private static JsonElement Parse(string json)
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
return doc.RootElement.Clone();
|
|
}
|
|
|
|
[Fact]
|
|
public void CanHandle_CameraCommands()
|
|
{
|
|
var cap = new CameraCapability(NullLogger.Instance);
|
|
Assert.True(cap.CanHandle("camera.list"));
|
|
Assert.True(cap.CanHandle("camera.snap"));
|
|
Assert.False(cap.CanHandle("camera.clip"));
|
|
Assert.Equal("camera", cap.Category);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_ReturnsError_WhenNoHandler()
|
|
{
|
|
var cap = new CameraCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest { Id = "cam1", Command = "camera.list", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_ReturnsCameras_WhenHandler()
|
|
{
|
|
var cap = new CameraCapability(NullLogger.Instance);
|
|
cap.ListRequested += () => Task.FromResult(new[]
|
|
{
|
|
new CameraInfo { DeviceId = "cam-1", Name = "Front", IsDefault = true },
|
|
new CameraInfo { DeviceId = "cam-2", Name = "Back", IsDefault = false }
|
|
});
|
|
|
|
var req = new NodeInvokeRequest { Id = "cam2", Command = "camera.list", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Snap_ReturnsError_WhenNoHandler()
|
|
{
|
|
var cap = new CameraCapability(NullLogger.Instance);
|
|
var req = new NodeInvokeRequest { Id = "cam3", Command = "camera.snap", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Snap_CallsHandler_WithArgs()
|
|
{
|
|
var cap = new CameraCapability(NullLogger.Instance);
|
|
CameraSnapArgs? receivedArgs = null;
|
|
cap.SnapRequested += (args) =>
|
|
{
|
|
receivedArgs = args;
|
|
return Task.FromResult(new CameraSnapResult { Format = "jpeg", Width = 640, Height = 480, Base64 = "img" });
|
|
};
|
|
|
|
var req = new NodeInvokeRequest
|
|
{
|
|
Id = "cam4",
|
|
Command = "camera.snap",
|
|
Args = Parse("""{"deviceId":"cam-1","format":"png","maxWidth":320,"quality":50}""")
|
|
};
|
|
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.NotNull(receivedArgs);
|
|
Assert.Equal("cam-1", receivedArgs!.DeviceId);
|
|
Assert.Equal("png", receivedArgs.Format);
|
|
Assert.Equal(320, receivedArgs.MaxWidth);
|
|
Assert.Equal(50, receivedArgs.Quality);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Snap_UsesDefaults_WhenArgsMissing()
|
|
{
|
|
var cap = new CameraCapability(NullLogger.Instance);
|
|
CameraSnapArgs? receivedArgs = null;
|
|
cap.SnapRequested += (args) =>
|
|
{
|
|
receivedArgs = args;
|
|
return Task.FromResult(new CameraSnapResult { Format = "jpeg", Width = 640, Height = 480, Base64 = "img" });
|
|
};
|
|
|
|
var req = new NodeInvokeRequest { Id = "cam5", Command = "camera.snap", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.True(res.Ok);
|
|
Assert.Null(receivedArgs!.DeviceId);
|
|
Assert.Equal("jpeg", receivedArgs.Format);
|
|
Assert.Equal(1280, receivedArgs.MaxWidth);
|
|
Assert.Equal(80, receivedArgs.Quality);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Snap_ReturnsError_WhenHandlerThrows()
|
|
{
|
|
var cap = new CameraCapability(NullLogger.Instance);
|
|
cap.SnapRequested += (args) => throw new InvalidOperationException("Camera access blocked");
|
|
|
|
var req = new NodeInvokeRequest { Id = "cam6", Command = "camera.snap", Args = Parse("""{}""") };
|
|
var res = await cap.ExecuteAsync(req);
|
|
Assert.False(res.Ok);
|
|
Assert.Contains("Camera access blocked", res.Error);
|
|
}
|
|
}
|