2026-07-27 22:02:34 -07:00
|
|
|
using System.Text.Json;
|
|
|
|
|
using NucleicBroker;
|
|
|
|
|
|
|
|
|
|
namespace NucleicBroker.Tests;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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).
|
|
|
|
|
/// </summary>
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Send one request line and return every line emitted once drained.</summary>
|
|
|
|
|
private async Task<List<JsonElement>> 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
|
2026-07-29 02:52:41 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[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);
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 04:04:16 -07:00
|
|
|
/// <summary>
|
|
|
|
|
/// D13 Tier 1 (docs/WINDOWS_PORT.md §13.2): the facade auto-recovers a session orphaned by a
|
|
|
|
|
/// dead broker, but when the internal COM arm did not bind there is nothing it can do — and
|
|
|
|
|
/// hostd must be able to tell *that* apart from an ordinary start failure, because the remedy
|
|
|
|
|
/// is `wsl --shutdown` rather than a retry. This pins the kind the Swift side branches on.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task SessionEnsure_UnrecoverableConflict_SurfacesSessionExists()
|
|
|
|
|
{
|
|
|
|
|
wslc.NextError = new WslcError(WslcError.SessionExists, "already running, cannot re-adopt");
|
|
|
|
|
var lines = await RoundTrip(
|
|
|
|
|
"""{"jsonrpc":"2.0","id":9,"method":"session.ensure","params":{"name":"nucleic-dev","dataDir":"C:\\data"}}""");
|
|
|
|
|
var error = Assert.Single(lines).GetProperty("error");
|
|
|
|
|
Assert.Equal(Rpc.FacadeError, error.GetProperty("code").GetInt32());
|
|
|
|
|
Assert.Equal("session_exists", error.GetProperty("data").GetProperty("kind").GetString());
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 17:09:35 -07:00
|
|
|
/// <summary>
|
|
|
|
|
/// Regression for the bug the first live run hit (docs/WINDOWS_PORT.md §13.3): a command that
|
|
|
|
|
/// finishes during start — `echo` — used to put `proc.stdout`/`proc.exit` on the wire BEFORE
|
|
|
|
|
/// the response carrying the `procId` that names them. A client that registers interest when
|
|
|
|
|
/// it learns the procId then never sees the exit and waits forever, which is exactly how the
|
|
|
|
|
/// spike hung.
|
|
|
|
|
///
|
|
|
|
|
/// The fix is that BrokerService responds before calling StartAsync, so this asserts the
|
|
|
|
|
/// ORDER and not merely that the messages all arrived.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ProcExec_RespondsBeforeAnyOutputFromAnInstantProcess()
|
|
|
|
|
{
|
|
|
|
|
wslc.ContainerStates["box"] = "running";
|
|
|
|
|
// The fake emits during StartAsync, standing in for a process that has already exited by
|
|
|
|
|
// the time Start() returns.
|
|
|
|
|
wslc.NextProcessOnStart = () =>
|
|
|
|
|
{
|
|
|
|
|
wslc.Events!.ProcOutput(wslc.LastProcId, stderr: false, "done\n"u8);
|
|
|
|
|
wslc.Events!.ProcExited(wslc.LastProcId, 0);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var lines = await RoundTrip(
|
|
|
|
|
"""{"jsonrpc":"2.0","id":1,"method":"proc.exec","params":{"container":"box","argv":["echo","done"],"tty":false}}""");
|
|
|
|
|
|
|
|
|
|
Assert.True(wslc.LastProcess!.Started, "BrokerService must start the process");
|
|
|
|
|
// Line 0 must be the response: everything else is meaningless to a client that cannot yet
|
|
|
|
|
// attribute the notifications.
|
|
|
|
|
var procId = Result(lines[0]).GetProperty("procId").GetInt64();
|
|
|
|
|
Assert.Equal(wslc.LastProcId, procId);
|
|
|
|
|
var methods = lines.Skip(1)
|
|
|
|
|
.Select(l => l.TryGetProperty("method", out var m) ? m.GetString() : null).ToList();
|
|
|
|
|
Assert.Equal(["proc.stdout", "proc.exit"], methods);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The response is enqueued before the process is started, so a start failure can no longer be
|
|
|
|
|
/// a JSON-RPC error — that would put TWO responses under one id. Observed on hardware
|
|
|
|
|
/// (docs/WINDOWS_PORT.md §13.3), where the client then waited for an exit that never came.
|
|
|
|
|
/// It must arrive as the process's own stderr plus an exit instead.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ProcExec_StartFailure_ReportsAsExitNotSecondResponse()
|
|
|
|
|
{
|
|
|
|
|
wslc.ContainerStates["box"] = "running";
|
|
|
|
|
wslc.NextProcessOnStart = () => throw new WslcError(WslcError.StartFailed, "container is not running");
|
|
|
|
|
|
|
|
|
|
var lines = await RoundTrip(
|
|
|
|
|
"""{"jsonrpc":"2.0","id":1,"method":"proc.exec","params":{"container":"box","argv":["sh"],"tty":false}}""");
|
|
|
|
|
|
|
|
|
|
// Exactly one message carries id 1 — anything else is a protocol violation.
|
|
|
|
|
var responses = lines.Where(l => l.TryGetProperty("id", out var i) && i.GetInt32() == 1).ToList();
|
|
|
|
|
Assert.Single(responses);
|
|
|
|
|
Assert.False(responses[0].TryGetProperty("error", out _), "must be the success response");
|
|
|
|
|
|
|
|
|
|
var methods = lines.Skip(1)
|
|
|
|
|
.Select(l => l.TryGetProperty("method", out var m) ? m.GetString() : null).ToList();
|
|
|
|
|
Assert.Equal(["proc.stderr", "proc.exit"], methods);
|
|
|
|
|
Assert.Equal(126, lines.Last().GetProperty("params").GetProperty("code").GetInt32());
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
[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());
|
|
|
|
|
}
|
|
|
|
|
}
|