795eee72e3
Adds missing <summary>/<param> docs across the .NET client library and its test suite so CommentChecker reports zero issues. TreatWarningsAsErrors requires the analyzer surface clean before publishing the NuGet package.
1056 lines
39 KiB
C#
1056 lines
39 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.MxGateway.Client.Cli;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|
|
|
/// <summary>Tests for the CLI command interface.</summary>
|
|
public sealed class MxGatewayClientCliTests
|
|
{
|
|
/// <summary>Verifies that the version command prints compiled protocol versions.</summary>
|
|
[Fact]
|
|
public void Run_Version_PrintsCompiledProtocolVersions()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("gateway-protocol=3", output.ToString());
|
|
Assert.Contains("worker-protocol=1", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("\"gatewayProtocolVersion\":3", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.Write,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"write",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--session-id",
|
|
"session-fixture",
|
|
"--server-handle",
|
|
"12",
|
|
"--item-handle",
|
|
"34",
|
|
"--type",
|
|
"int32",
|
|
"--value",
|
|
"123",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
|
|
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
|
Assert.Equal(123, request.Command.Write.Value.Int32Value);
|
|
Assert.Contains("MX_COMMAND_KIND_WRITE", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"open-session",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"secret-api-key",
|
|
],
|
|
output,
|
|
error,
|
|
_ => throw new InvalidOperationException("boom secret-api-key"));
|
|
|
|
Assert.Equal(1, exitCode);
|
|
Assert.DoesNotContain("secret-api-key", error.ToString());
|
|
Assert.Contains("[redacted]", error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.Events.Add(new MxEvent
|
|
{
|
|
SessionId = "session-fixture",
|
|
Family = MxEventFamily.OnDataChange,
|
|
WorkerSequence = 1,
|
|
});
|
|
fakeClient.Events.Add(new MxEvent
|
|
{
|
|
SessionId = "session-fixture",
|
|
Family = MxEventFamily.OnWriteComplete,
|
|
WorkerSequence = 2,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"stream-events",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--session-id",
|
|
"session-fixture",
|
|
"--max-events",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Contains("workerSequence", output.ToString());
|
|
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
|
}
|
|
|
|
|
|
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
|
{
|
|
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "Tank01.Level.HiHi" },
|
|
});
|
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage { SnapshotComplete = true });
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"stream-alarms",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--filter-prefix",
|
|
"Tank01",
|
|
"--max-events",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
StreamAlarmsRequest request = Assert.Single(fakeClient.StreamAlarmsRequests);
|
|
Assert.Equal("Tank01", request.AlarmFilterPrefix);
|
|
string text = output.ToString();
|
|
Assert.Contains("active-alarm", text);
|
|
Assert.Contains("Tank01.Level.HiHi", text);
|
|
Assert.DoesNotContain("snapshot-complete", text);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
|
{
|
|
CorrelationId = "ack-fixture",
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Hresult = 0,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"acknowledge-alarm",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--reference",
|
|
"Tank01.Level.HiHi",
|
|
"--comment",
|
|
"ack from cli",
|
|
"--operator",
|
|
"operator1",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
AcknowledgeAlarmRequest request = Assert.Single(fakeClient.AcknowledgeAlarmRequests);
|
|
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
|
Assert.Equal("ack from cli", request.Comment);
|
|
Assert.Equal("operator1", request.OperatorUser);
|
|
Assert.Contains("ack-fixture", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new()
|
|
{
|
|
InvokeFailure = new InvalidOperationException("register failed"),
|
|
};
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"smoke",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--item",
|
|
"Area001.Pump001.Speed",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(1, exitCode);
|
|
CloseSessionRequest closeRequest = Assert.Single(fakeClient.CloseSessionRequests);
|
|
Assert.Equal("session-fixture", closeRequest.SessionId);
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new()
|
|
{
|
|
GalaxyTestConnectionReply = new TestConnectionReply { Ok = true },
|
|
};
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-test-connection",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Single(fakeClient.GalaxyTestConnectionRequests);
|
|
Assert.Contains("\"ok\": true", output.ToString());
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
NextPageToken = "7:1",
|
|
TotalObjectCount = 2,
|
|
Objects =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 7,
|
|
TagName = "DelmiaReceiver_001",
|
|
ContainedName = "DelmiaReceiver",
|
|
ParentGobjectId = 1,
|
|
Attributes =
|
|
{
|
|
new GalaxyAttribute
|
|
{
|
|
AttributeName = "DownloadPath",
|
|
FullTagReference = "DelmiaReceiver_001.DownloadPath",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
|
{
|
|
TotalObjectCount = 2,
|
|
Objects =
|
|
{
|
|
new GalaxyObject
|
|
{
|
|
GobjectId = 8,
|
|
TagName = "DelmiaReceiver_002",
|
|
ContainedName = "DelmiaReceiver",
|
|
ParentGobjectId = 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-discover",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
Assert.Equal(2, fakeClient.GalaxyDiscoverHierarchyRequests.Count);
|
|
Assert.Equal(5000, fakeClient.GalaxyDiscoverHierarchyRequests[0].PageSize);
|
|
Assert.Equal("", fakeClient.GalaxyDiscoverHierarchyRequests[0].PageToken);
|
|
Assert.Equal("7:1", fakeClient.GalaxyDiscoverHierarchyRequests[1].PageToken);
|
|
string text = output.ToString();
|
|
Assert.Contains("objects=2", text);
|
|
Assert.Contains("DelmiaReceiver_001", text);
|
|
Assert.Contains("DelmiaReceiver_002", text);
|
|
Assert.Contains("attributes=1", text);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
DateTime deploy = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
|
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 1,
|
|
ObservedAt = Timestamp.FromDateTime(deploy),
|
|
TimeOfLastDeploy = Timestamp.FromDateTime(deploy),
|
|
TimeOfLastDeployPresent = true,
|
|
ObjectCount = 5,
|
|
AttributeCount = 17,
|
|
});
|
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 2,
|
|
ObservedAt = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
|
TimeOfLastDeploy = Timestamp.FromDateTime(deploy.AddSeconds(30)),
|
|
TimeOfLastDeployPresent = true,
|
|
ObjectCount = 6,
|
|
AttributeCount = 18,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-watch",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--last-seen-deploy-time",
|
|
"2026-04-28T14:00:00Z",
|
|
"--max-events",
|
|
"2",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
WatchDeployEventsRequest request = Assert.Single(fakeClient.GalaxyWatchDeployEventsRequests);
|
|
Assert.NotNull(request.LastSeenDeployTime);
|
|
string text = output.ToString();
|
|
Assert.Contains("sequence=1", text);
|
|
Assert.Contains("sequence=2", text);
|
|
Assert.Contains("objects=5", text);
|
|
Assert.Contains("attributes=18", text);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
|
|
{
|
|
Sequence = 42,
|
|
ObjectCount = 99,
|
|
AttributeCount = 1024,
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"galaxy-watch",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--max-events",
|
|
"1",
|
|
"--json",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
string text = output.ToString();
|
|
Assert.Contains("\"sequence\": \"42\"", text);
|
|
Assert.Contains("\"objectCount\": 99", text);
|
|
}
|
|
|
|
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
using var input = new StringReader("version --json\n");
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
["batch"],
|
|
output,
|
|
error,
|
|
clientFactory: null,
|
|
standardInput: input);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
string text = output.ToString();
|
|
Assert.Contains("\"gatewayProtocolVersion\":3", text);
|
|
Assert.Contains("__MXGW_BATCH_EOR__", text);
|
|
// The EOR marker must come after the JSON output.
|
|
int jsonIndex = text.IndexOf("\"gatewayProtocolVersion\"", StringComparison.Ordinal);
|
|
int eorIndex = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
|
Assert.True(jsonIndex >= 0 && eorIndex > jsonIndex);
|
|
Assert.Equal(string.Empty, error.ToString());
|
|
}
|
|
|
|
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
|
|
[Fact]
|
|
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
// Unknown command should produce an error on the captured error stream,
|
|
// which batch mode re-emits to stdout inside the same delimited block.
|
|
using var input = new StringReader("nope-not-a-command\nversion\n");
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
["batch"],
|
|
output,
|
|
error,
|
|
clientFactory: null,
|
|
standardInput: input);
|
|
|
|
Assert.Equal(0, exitCode);
|
|
string text = output.ToString();
|
|
// Two records → two EOR markers.
|
|
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
|
|
int secondEor = text.IndexOf(
|
|
"__MXGW_BATCH_EOR__",
|
|
firstEor + 1,
|
|
StringComparison.Ordinal);
|
|
Assert.True(firstEor > 0);
|
|
Assert.True(secondEor > firstEor);
|
|
// The unknown-command error message must be on stdout (not on stderr).
|
|
Assert.Contains("nope-not-a-command", text);
|
|
Assert.DoesNotContain("nope-not-a-command", error.ToString());
|
|
// The follow-up `version` line should still succeed.
|
|
Assert.Contains("gateway-protocol=", text);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client.Dotnet-018: the README CLI examples for the alarm subcommands at
|
|
/// `clients/dotnet/README.md` must drive cleanly through the production
|
|
/// CLI argument parser. The previous text used non-existent flags
|
|
/// (`--session-id`, `--max-messages`, `--alarm-reference`) that would
|
|
/// fail with "Unknown command" / "Missing required option --reference".
|
|
/// Each documented example is extracted from the README, parsed via the
|
|
/// production <see cref="MxGatewayClientCli.RunAsync"/>, and asserted
|
|
/// against exit code 0.
|
|
/// </summary>
|
|
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
|
|
[Theory]
|
|
[InlineData("stream-alarms")]
|
|
[InlineData("acknowledge-alarm")]
|
|
public async Task RunAsync_ReadmeExamples_ForAlarmCommands_ParseSuccessfully(string command)
|
|
{
|
|
string readme = LocateClientReadme();
|
|
string[] commandLine = ExtractReadmeCommandLine(readme, command);
|
|
// The documented examples do not include --api-key (the README assumes
|
|
// the env var path documented elsewhere). Inject an API key via the
|
|
// standard env var so CreateOptions succeeds and the parser fully
|
|
// exercises the documented flag shape.
|
|
string? previousKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
|
|
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", "test-api-key");
|
|
|
|
try
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
|
{
|
|
ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "fixture" },
|
|
});
|
|
fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
|
|
{
|
|
CorrelationId = "ack-fixture",
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
commandLine,
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.True(
|
|
exitCode == 0,
|
|
$"README example for '{command}' exited {exitCode}; stderr=<<{error}>>");
|
|
Assert.DoesNotContain("Unknown command", error.ToString());
|
|
Assert.DoesNotContain("Missing required option", error.ToString());
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", previousKey);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client.Dotnet-019: `BenchReadBulkAsync` previously fell back to
|
|
/// <c>reply.ReturnValue.Int32Value</c> when the register reply had no
|
|
/// typed <c>Register</c> payload, silently driving the rest of the bench
|
|
/// against a zero server handle. The fix must fail loudly with a
|
|
/// descriptive <see cref="MxGatewayException"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
// Successful protocol + MX status but no typed `Register` payload.
|
|
// Before the Client.Dotnet-019 fix this silently became serverHandle=0
|
|
// and the bench proceeded through SubscribeBulk / warmup / steady-state
|
|
// against an invalid handle, producing a misleading zero-result summary.
|
|
fakeClient.InvokeReplies.Enqueue(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.Register,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
});
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"bench-read-bulk",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--duration-seconds",
|
|
"1",
|
|
"--warmup-seconds",
|
|
"0",
|
|
"--bulk-size",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.Equal(1, exitCode);
|
|
// Descriptive message that names the missing typed payload.
|
|
string err = error.ToString();
|
|
Assert.Contains("Register", err);
|
|
// The bench must not produce any aggregate stats JSON.
|
|
Assert.DoesNotContain("bench-read-bulk", output.ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client.Dotnet-020: the steady-state loop in `BenchReadBulkAsync` had a
|
|
/// bare `catch { failedCalls++; continue; }` that swallowed
|
|
/// <see cref="OperationCanceledException"/>, so token-driven cancellation
|
|
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
|
|
/// the bench must exit promptly when the supplied token cancels.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
|
|
int invokeCount = 0;
|
|
FakeCliClient fakeClient = new()
|
|
{
|
|
InvokeHandler = (request, ct) =>
|
|
{
|
|
int n = Interlocked.Increment(ref invokeCount);
|
|
|
|
// Reply 1 = Register (success with typed payload).
|
|
if (request.Command.Kind == MxCommandKind.Register)
|
|
{
|
|
return Task.FromResult(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.Register,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
Register = new RegisterReply { ServerHandle = 1 },
|
|
});
|
|
}
|
|
|
|
// Reply 2 = SubscribeBulk (success).
|
|
if (request.Command.Kind == MxCommandKind.SubscribeBulk)
|
|
{
|
|
var subscribeReply = new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.SubscribeBulk,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
SubscribeBulk = new BulkSubscribeReply(),
|
|
};
|
|
return Task.FromResult(subscribeReply);
|
|
}
|
|
|
|
// ReadBulk reply 1 = success (so the steady-state loop enters
|
|
// and starts iterating). Reply 2+ = simulated cancellation.
|
|
if (request.Command.Kind == MxCommandKind.ReadBulk && n <= 3)
|
|
{
|
|
return Task.FromResult(new MxCommandReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
Kind = MxCommandKind.ReadBulk,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
ReadBulk = new BulkReadReply(),
|
|
});
|
|
}
|
|
|
|
// From here on every ReadBulk throws OCE — the steady-state
|
|
// loop must exit promptly rather than spinning until
|
|
// --duration-seconds elapses.
|
|
throw new OperationCanceledException();
|
|
},
|
|
};
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
|
await MxGatewayClientCli.RunAsync(
|
|
[
|
|
"bench-read-bulk",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--duration-seconds",
|
|
"30",
|
|
"--warmup-seconds",
|
|
"0",
|
|
"--bulk-size",
|
|
"1",
|
|
],
|
|
output,
|
|
error,
|
|
_ => fakeClient));
|
|
sw.Stop();
|
|
|
|
// Without the fix the loop swallows OCE and continues until the 30 s
|
|
// steady-state deadline expires. With the fix it exits as soon as OCE
|
|
// surfaces. Generous 10 s ceiling to keep the test stable under load.
|
|
Assert.True(
|
|
sw.Elapsed < TimeSpan.FromSeconds(10),
|
|
$"Bench did not exit promptly on cancellation; took {sw.Elapsed}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client.Dotnet-021: both `ReadBulkAsync` and `BenchReadBulkAsync` cast
|
|
/// the user-supplied <c>--timeout-ms</c> to <see cref="uint"/> without
|
|
/// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
|
|
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
|
/// </summary>
|
|
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
|
|
[Theory]
|
|
[InlineData("read-bulk")]
|
|
[InlineData("bench-read-bulk")]
|
|
public async Task RunAsync_TimeoutMs_NegativeValue_RejectsWithClearError(string command)
|
|
{
|
|
using var output = new StringWriter();
|
|
using var error = new StringWriter();
|
|
FakeCliClient fakeClient = new();
|
|
|
|
string[] args = command is "read-bulk"
|
|
? [
|
|
"read-bulk",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--session-id",
|
|
"session-fixture",
|
|
"--server-handle",
|
|
"1",
|
|
"--items",
|
|
"Area001.Pump001.Speed",
|
|
"--timeout-ms",
|
|
"-1",
|
|
]
|
|
: [
|
|
"bench-read-bulk",
|
|
"--endpoint",
|
|
"http://localhost:5000",
|
|
"--api-key",
|
|
"test-api-key",
|
|
"--duration-seconds",
|
|
"1",
|
|
"--warmup-seconds",
|
|
"0",
|
|
"--bulk-size",
|
|
"1",
|
|
"--timeout-ms",
|
|
"-1",
|
|
];
|
|
|
|
int exitCode = await MxGatewayClientCli.RunAsync(
|
|
args,
|
|
output,
|
|
error,
|
|
_ => fakeClient);
|
|
|
|
Assert.NotEqual(0, exitCode);
|
|
string err = error.ToString();
|
|
Assert.Contains("timeout-ms", err);
|
|
Assert.Contains("non-negative", err);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Locates the .NET client README by walking up from the test assembly's
|
|
/// base directory until <c>clients/dotnet/README.md</c> is found. Keeps
|
|
/// the regression test independent of the current working directory.
|
|
/// </summary>
|
|
private static string LocateClientReadme()
|
|
{
|
|
string? directory = AppContext.BaseDirectory;
|
|
while (!string.IsNullOrEmpty(directory))
|
|
{
|
|
string candidate = Path.Combine(directory, "clients", "dotnet", "README.md");
|
|
if (File.Exists(candidate))
|
|
{
|
|
return candidate;
|
|
}
|
|
|
|
directory = Path.GetDirectoryName(directory);
|
|
}
|
|
|
|
throw new FileNotFoundException("clients/dotnet/README.md not found above test assembly base directory.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the documented CLI invocation for the requested subcommand
|
|
/// from the README, returning only the arguments after the
|
|
/// <c>mxgw-dotnet</c>-equivalent prefix so they can be passed straight
|
|
/// to <see cref="MxGatewayClientCli.RunAsync"/>.
|
|
/// </summary>
|
|
private static string[] ExtractReadmeCommandLine(string readmePath, string command)
|
|
{
|
|
string[] lines = File.ReadAllLines(readmePath);
|
|
// Look for the documented `dotnet run ... -- <command> ...` line.
|
|
foreach (string line in lines)
|
|
{
|
|
int dashes = line.IndexOf("-- " + command, StringComparison.Ordinal);
|
|
if (dashes < 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string after = line[(dashes + 3)..].Trim();
|
|
// Tokenize by whitespace, respecting "..." quoted segments.
|
|
return TokenizeCommandLine(after);
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
$"README at '{readmePath}' has no documented example for subcommand '{command}'.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Splits a single command-line string into argv tokens, honouring
|
|
/// double-quoted segments so paths with embedded spaces survive intact.
|
|
/// </summary>
|
|
private static string[] TokenizeCommandLine(string input)
|
|
{
|
|
var tokens = new List<string>();
|
|
var current = new System.Text.StringBuilder();
|
|
bool inQuotes = false;
|
|
|
|
foreach (char ch in input)
|
|
{
|
|
if (ch == '"')
|
|
{
|
|
inQuotes = !inQuotes;
|
|
continue;
|
|
}
|
|
|
|
if (!inQuotes && char.IsWhiteSpace(ch))
|
|
{
|
|
if (current.Length > 0)
|
|
{
|
|
tokens.Add(current.ToString());
|
|
current.Clear();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
current.Append(ch);
|
|
}
|
|
|
|
if (current.Length > 0)
|
|
{
|
|
tokens.Add(current.ToString());
|
|
}
|
|
|
|
return tokens.ToArray();
|
|
}
|
|
|
|
/// <summary>Fake CLI client for testing.</summary>
|
|
private sealed class FakeCliClient : IMxGatewayCliClient
|
|
{
|
|
/// <summary>Queue of invoke replies to return.</summary>
|
|
public Queue<MxCommandReply> InvokeReplies { get; } = new();
|
|
|
|
/// <summary>List of received invoke requests.</summary>
|
|
public List<MxCommandRequest> InvokeRequests { get; } = [];
|
|
|
|
/// <summary>List of received close session requests.</summary>
|
|
public List<CloseSessionRequest> CloseSessionRequests { get; } = [];
|
|
|
|
/// <summary>List of events to yield when streaming.</summary>
|
|
public List<MxEvent> Events { get; } = [];
|
|
|
|
/// <summary>Exception to throw on invoke, if any.</summary>
|
|
public Exception? InvokeFailure { get; init; }
|
|
|
|
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
|
|
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<OpenSessionReply> OpenSessionAsync(
|
|
OpenSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(new OpenSessionReply
|
|
{
|
|
SessionId = "session-fixture",
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
GatewayProtocolVersion = 1,
|
|
WorkerProtocolVersion = 1,
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<CloseSessionReply> CloseSessionAsync(
|
|
CloseSessionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
CloseSessionRequests.Add(request);
|
|
return Task.FromResult(new CloseSessionReply
|
|
{
|
|
SessionId = request.SessionId,
|
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
|
FinalState = SessionState.Closed,
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<MxCommandReply> InvokeAsync(
|
|
MxCommandRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
InvokeRequests.Add(request);
|
|
if (InvokeFailure is not null)
|
|
{
|
|
throw InvokeFailure;
|
|
}
|
|
|
|
if (InvokeHandler is not null)
|
|
{
|
|
return InvokeHandler(request, cancellationToken);
|
|
}
|
|
|
|
return Task.FromResult(InvokeReplies.Dequeue());
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
|
StreamEventsRequest request,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
foreach (MxEvent gatewayEvent in Events)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
yield return gatewayEvent;
|
|
}
|
|
}
|
|
|
|
/// <summary>Queue of acknowledge-alarm replies to return.</summary>
|
|
public Queue<AcknowledgeAlarmReply> AcknowledgeAlarmReplies { get; } = new();
|
|
|
|
/// <summary>List of received acknowledge-alarm requests.</summary>
|
|
public List<AcknowledgeAlarmRequest> AcknowledgeAlarmRequests { get; } = [];
|
|
|
|
/// <summary>List of received stream-alarms requests.</summary>
|
|
public List<StreamAlarmsRequest> StreamAlarmsRequests { get; } = [];
|
|
|
|
/// <summary>List of alarm feed messages to yield when streaming alarms.</summary>
|
|
public List<AlarmFeedMessage> AlarmFeedMessages { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
|
AcknowledgeAlarmRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
AcknowledgeAlarmRequests.Add(request);
|
|
return Task.FromResult(AcknowledgeAlarmReplies.Dequeue());
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
|
StreamAlarmsRequest request,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
StreamAlarmsRequests.Add(request);
|
|
foreach (AlarmFeedMessage feedMessage in AlarmFeedMessages)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
yield return feedMessage;
|
|
}
|
|
}
|
|
|
|
/// <summary>Galaxy test connection reply to return.</summary>
|
|
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
|
|
|
|
/// <summary>Galaxy get last deploy time reply to return.</summary>
|
|
public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };
|
|
|
|
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
|
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
|
|
|
/// <summary>Queue of galaxy discover hierarchy replies to return.</summary>
|
|
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
|
|
|
/// <summary>List of received galaxy test connection requests.</summary>
|
|
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
|
|
|
|
/// <summary>List of received galaxy get last deploy time requests.</summary>
|
|
public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];
|
|
|
|
/// <summary>List of received galaxy discover hierarchy requests.</summary>
|
|
public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
|
|
TestConnectionRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyTestConnectionRequests.Add(request);
|
|
return Task.FromResult(GalaxyTestConnectionReply);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
|
|
GetLastDeployTimeRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyGetLastDeployTimeRequests.Add(request);
|
|
return Task.FromResult(GalaxyGetLastDeployTimeReply);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
|
|
DiscoverHierarchyRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
GalaxyDiscoverHierarchyRequests.Add(request);
|
|
return Task.FromResult(
|
|
GalaxyDiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
|
|
? reply
|
|
: GalaxyDiscoverHierarchyReply);
|
|
}
|
|
|
|
/// <summary>List of received galaxy watch deploy events requests.</summary>
|
|
public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];
|
|
|
|
/// <summary>List of deploy events to yield when watching.</summary>
|
|
public List<DeployEvent> GalaxyDeployEvents { get; } = [];
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
|
|
WatchDeployEventsRequest request,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
GalaxyWatchDeployEventsRequests.Add(request);
|
|
foreach (DeployEvent deployEvent in GalaxyDeployEvents)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
await Task.Yield();
|
|
yield return deployEvent;
|
|
}
|
|
}
|
|
}
|
|
}
|