using System.Text.Json; using NucleicBroker; namespace NucleicBroker.Tests; /// /// Contract tests for the §3.3 RPC surface: each test drives BrokerService with raw NDJSON /// lines (exactly what hostd sends) and asserts on the emitted lines (exactly what hostd's /// WslcBrokerClient parses). The Swift side's fake-broker tests mirror these — together they /// pin the contract from both ends (docs/WINDOWS_PORT.md §14). /// public sealed class BrokerServiceTests { private readonly FakeWslc wslc = new(); private readonly StringWriter captured = new(); private readonly OutboundWriter outbound; private readonly BrokerService service; public BrokerServiceTests() { outbound = new OutboundWriter(captured); service = new BrokerService(wslc, new UnavailableAiProvider(), outbound); } /// Send one request line and return every line emitted once drained. private async Task> RoundTrip(params string[] lines) { foreach (var line in lines) await service.HandleLineAsync(line); await outbound.CompleteAsync(); return captured.ToString() .Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(l => JsonDocument.Parse(l).RootElement.Clone()) .ToList(); } private static JsonElement Result(JsonElement response) { Assert.Equal("2.0", response.GetProperty("jsonrpc").GetString()); Assert.False(response.TryGetProperty("error", out var error), error.ToString()); return response.GetProperty("result"); } [Fact] public async Task Hello_ReportsProtocolAndCapabilities() { var lines = await RoundTrip("""{"jsonrpc":"2.0","id":1,"method":"hello"}"""); var result = Result(Assert.Single(lines)); Assert.Equal(BrokerService.ProtocolVersion, result.GetProperty("protocol").GetInt32()); Assert.Equal("9.9-test", result.GetProperty("wslcVersion").GetString()); var caps = result.GetProperty("capabilities").EnumerateArray().Select(c => c.GetString()).ToList(); Assert.Contains("proc", caps); Assert.DoesNotContain("ai", caps); // UnavailableAiProvider // The facade's own capabilities ride the same list as the RPC families, because hostd // needs both to decide what to attempt (docs/WINDOWS_PORT.md D13). Assert.Contains("tty", caps); Assert.Contains("reattach", caps); } /// /// A compat-SDK-only facade reports no `tty`/`reattach`/`enumerate`, and that MUST reach /// hostd through `hello` — it is the whole degradation mechanism D13 relies on, and the /// difference between the Terminal panel being disabled and it failing in the user's face. /// [Fact] public async Task Hello_OmitsCapabilitiesTheFacadeCannotServe() { wslc.CapabilityList = ["stats"]; // what WslcFacade reports with only the compat SDK var result = Result(Assert.Single(await RoundTrip("""{"jsonrpc":"2.0","id":1,"method":"hello"}"""))); var caps = result.GetProperty("capabilities").EnumerateArray().Select(c => c.GetString()).ToList(); Assert.Contains("stats", caps); Assert.DoesNotContain("tty", caps); Assert.DoesNotContain("reattach", caps); Assert.DoesNotContain("enumerate", caps); // The RPC families are still advertised: the methods exist and answer, they just answer // `unsupported` for the parts the surface cannot do. Assert.Contains("container", caps); Assert.Contains("proc", caps); } [Fact] public async Task UnknownMethod_ReturnsMethodNotFound() { var lines = await RoundTrip("""{"jsonrpc":"2.0","id":7,"method":"nope.nothing"}"""); var error = Assert.Single(lines).GetProperty("error"); Assert.Equal(Rpc.MethodNotFound, error.GetProperty("code").GetInt32()); } [Fact] public async Task MalformedLine_ReturnsParseErrorWithNullId() { var lines = await RoundTrip("this is not json"); var response = Assert.Single(lines); Assert.Equal(JsonValueKind.Null, response.GetProperty("id").ValueKind); Assert.Equal(Rpc.ParseError, response.GetProperty("error").GetProperty("code").GetInt32()); } [Fact] public async Task MissingParams_ReturnsInvalidParams() { var lines = await RoundTrip("""{"jsonrpc":"2.0","id":8,"method":"session.ensure"}"""); var error = Assert.Single(lines).GetProperty("error"); Assert.Equal(Rpc.InvalidParams, error.GetProperty("code").GetInt32()); } [Fact] public async Task SessionEnsure_ForwardsSpecAndReturnsGateway() { var lines = await RoundTrip( """{"jsonrpc":"2.0","id":2,"method":"session.ensure","params":{"name":"nucleic-dev","dataDir":"C:\\data","cpu":4,"memoryMB":8192}}"""); Assert.Equal("172.28.0.1", Result(Assert.Single(lines)).GetProperty("gateway").GetString()); Assert.Equal(new SessionSpec("nucleic-dev", @"C:\data", 4, 8192), wslc.LastSession); } [Fact] public async Task ImagePull_EmitsProgressNotificationsBeforeResult() { var lines = await RoundTrip( """{"jsonrpc":"2.0","id":3,"method":"image.pull","params":{"ref":"ghcr.io/abkslm/naros-agent:26.07"}}"""); Assert.Equal(3, lines.Count); Assert.Equal("image.pullProgress", lines[0].GetProperty("method").GetString()); Assert.Equal("ghcr.io/abkslm/naros-agent:26.07", lines[0].GetProperty("params").GetProperty("ref").GetString()); Assert.Equal("done", lines[1].GetProperty("params").GetProperty("status").GetString()); Result(lines[2]); // the pull's own success response arrives after its progress } [Fact] public async Task ContainerLifecycle_CreateStartStateStopDelete() { var lines = await RoundTrip( """{"jsonrpc":"2.0","id":1,"method":"container.create","params":{"name":"nucleic-control-local","image":"img","volumes":[{"host":"C:\\repo","guest":"/work","ro":false}],"env":{"A":"1"},"initArgv":["/sbin/init"]}}""", """{"jsonrpc":"2.0","id":2,"method":"container.start","params":{"name":"nucleic-control-local"}}""", """{"jsonrpc":"2.0","id":3,"method":"container.state","params":{"name":"nucleic-control-local"}}""", """{"jsonrpc":"2.0","id":4,"method":"container.stop","params":{"name":"nucleic-control-local","signal":15,"graceMs":2000}}""", """{"jsonrpc":"2.0","id":5,"method":"container.delete","params":{"name":"nucleic-control-local"}}""", """{"jsonrpc":"2.0","id":6,"method":"container.state","params":{"name":"nucleic-control-local"}}"""); Assert.Equal(6, lines.Count); Assert.Equal("running", Result(lines[2]).GetProperty("state").GetString()); Assert.Equal(("nucleic-control-local", 15, 2000), wslc.LastStop); Assert.Equal("absent", Result(lines[5]).GetProperty("state").GetString()); var create = wslc.LastCreate!; Assert.Equal("/work", Assert.Single(create.Volumes!).Guest); Assert.False(Assert.Single(create.Volumes!).ReadOnly); Assert.Equal("/sbin/init", Assert.Single(create.InitArgv!)); } [Fact] public async Task ProcExec_MintsIdAndRoutesStdioEventsInOrder() { wslc.ContainerStates["box"] = "running"; await service.HandleLineAsync( """{"jsonrpc":"2.0","id":1,"method":"proc.exec","params":{"container":"box","argv":["claude","-p"],"tty":false}}"""); // The facade emits output then exit, from its own (non-broker) thread. wslc.Events!.ProcOutput(wslc.LastProcId, stderr: false, "hel"u8); wslc.Events!.ProcOutput(wslc.LastProcId, stderr: false, "lo\n"u8); wslc.Events!.ProcExited(wslc.LastProcId, 0); await outbound.CompleteAsync(); var lines = captured.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(l => JsonDocument.Parse(l).RootElement.Clone()).ToList(); var procId = Result(lines[0]).GetProperty("procId").GetInt64(); Assert.Equal(wslc.LastProcId, procId); var stdout = lines.Where(l => l.TryGetProperty("method", out var m) && m.GetString() == "proc.stdout").ToList(); var decoded = string.Concat(stdout.Select(n => System.Text.Encoding.UTF8.GetString( Convert.FromBase64String(n.GetProperty("params").GetProperty("b64").GetString()!)))); Assert.Equal("hello\n", decoded); // exit is last, and never precedes the output that came before it Assert.Equal("proc.exit", lines[^1].GetProperty("method").GetString()); Assert.Equal(0, lines[^1].GetProperty("params").GetProperty("code").GetInt32()); } [Fact] public async Task ProcStdin_DecodesBase64AndSupportsSignalCloseResize() { wslc.ContainerStates["box"] = "running"; await service.HandleLineAsync( """{"jsonrpc":"2.0","id":1,"method":"proc.exec","params":{"container":"box","argv":["sh"],"tty":true}}"""); var b64 = Convert.ToBase64String("{\"x\":1}\n"u8.ToArray()); // The trailing literal `} }` pairs are spaced apart: inside a `$$"""…"""` string two // consecutive closing braces would read as an interpolation delimiter (CS9007), and // whitespace between JSON braces is insignificant. var lines = await RoundTrip( $$"""{"jsonrpc":"2.0","id":2,"method":"proc.stdin","params":{"procId":{{wslc.LastProcId}},"b64":"{{b64}}"} }""", $$"""{"jsonrpc":"2.0","id":3,"method":"proc.signal","params":{"procId":{{wslc.LastProcId}},"sig":2} }""", $$"""{"jsonrpc":"2.0","id":4,"method":"proc.resize","params":{"procId":{{wslc.LastProcId}},"cols":120,"rows":40} }""", $$"""{"jsonrpc":"2.0","id":5,"method":"proc.closeStdin","params":{"procId":{{wslc.LastProcId}} } }"""); Assert.Equal(5, lines.Count); // exec response + 4 above var proc = wslc.LastProcess!; Assert.Equal("{\"x\":1}\n", System.Text.Encoding.UTF8.GetString(proc.Stdin.ToArray())); Assert.Equal([2], proc.Signals); Assert.Equal((120, 40), proc.LastResize); Assert.True(proc.StdinClosed); } [Fact] public async Task UnknownProcId_ReturnsFacadeErrorWithKind() { var lines = await RoundTrip( """{"jsonrpc":"2.0","id":1,"method":"proc.signal","params":{"procId":999,"sig":9}}"""); var error = Assert.Single(lines).GetProperty("error"); Assert.Equal(Rpc.FacadeError, error.GetProperty("code").GetInt32()); Assert.Equal("not_found", error.GetProperty("data").GetProperty("kind").GetString()); } [Fact] public async Task FacadeError_SurfacesStructuredKind() { wslc.NextError = new WslcError(WslcError.PullFailed, "registry said no"); var lines = await RoundTrip( """{"jsonrpc":"2.0","id":1,"method":"image.pull","params":{"ref":"ghcr.io/x/y:1"}}"""); var error = Assert.Single(lines).GetProperty("error"); Assert.Equal("image_pull_failed", error.GetProperty("data").GetProperty("kind").GetString()); Assert.Equal("registry said no", error.GetProperty("message").GetString()); } [Fact] public async Task SessionDown_ForwardsAsNotification() { wslc.Events!.SessionDown("vm terminated"); await outbound.CompleteAsync(); var lines = await Task.FromResult(captured.ToString() .Split('\n', StringSplitOptions.RemoveEmptyEntries) .Select(l => JsonDocument.Parse(l).RootElement.Clone()).ToList()); var note = Assert.Single(lines); Assert.Equal("session.down", note.GetProperty("method").GetString()); Assert.Equal("vm terminated", note.GetProperty("params").GetProperty("reason").GetString()); } [Fact] public async Task AiGenerate_UnavailableProviderReturnsStructuredError() { var lines = await RoundTrip( """{"jsonrpc":"2.0","id":1,"method":"ai.generate","params":{"prompt":"hi"}}"""); var error = Assert.Single(lines).GetProperty("error"); Assert.Equal("ai_unavailable", error.GetProperty("data").GetProperty("kind").GetString()); } [Fact] public async Task ExecInStoppedContainer_ReturnsNotRunning() { wslc.ContainerStates["box"] = "stopped"; var lines = await RoundTrip( """{"jsonrpc":"2.0","id":1,"method":"proc.exec","params":{"container":"box","argv":["sh"]}}"""); var error = Assert.Single(lines).GetProperty("error"); Assert.Equal("not_running", error.GetProperty("data").GetProperty("kind").GetString()); } }