e2e: port batch subcommand to all five client CLIs

scripts/run-client-e2e-tests.ps1 expects each language CLI to expose a
`batch` subcommand that reads command lines from stdin, runs each
through the normal subcommand dispatch, writes the JSON result, then
a sentinel line `__MXGW_BATCH_EOR__`. The implementation lived on a
divergent branch (commit 6126099) that was never merged into main —
this commit ports the same protocol to HEAD's renamed CLIs so the
existing matrix script runs end-to-end.

The protocol:
  - one line of stdin = one full CLI invocation
  - successful output → stdout, then __MXGW_BATCH_EOR__
  - failure → {"error":"...","type":"error"} JSON on stdout, then
    __MXGW_BATCH_EOR__ (errors do NOT exit the loop)
  - empty line or EOF terminates the loop

Per-CLI additions:

  .NET: RunBatchAsync + per-line StringWriter capture, JSON error
    envelope when forceJsonErrors is true. Two new tests in
    MxGatewayClientCliTests covering the success and error paths.

  Go:   runBatch with bufio.Scanner, runs each line through the
    existing runWithIO switch with a buffered stdout writer. One new
    test pinning the EOR sentinel.

  Rust: new `Batch` variant on the clap Command enum, run_batch
    re-parses each line via Cli::try_parse_from. Two new tests in the
    inline mod tests block.

  Python: new `batch` click command in commands.py that uses
    CliRunner to dispatch each line; synthesises {"error",..."type"}
    JSON from click error messages when the captured output isn't
    already JSON-shaped. Three new tests in test_cli.py.

  Java: BatchCommand inner @Command with BufferedReader stdin loop,
    fresh commandLine() per dispatch with captured stdout/stderr
    PrintWriters; non-zero exit codes and uncaught exceptions both
    surface as JSON-error blocks. Two new tests.

Also fixes scripts/run-client-e2e-tests.ps1 line 705: the Python
invocation was still passing the old module name `mxgateway_cli` to
`python -m`; the client SDK rename in 397d3c5 moved it to
`zb_mom_ww_mxgateway_cli`. Without the fix the Python leg fails
with "No module named mxgateway_cli" before reaching open-session.

Verification: full matrix at the redeployed gateway (localhost:5120,
running ZB.MOM.WW.MxGateway.Server.exe / ZB.MOM.WW.MxGateway.Worker.exe)
with -SkipBulk -SkipReadWriteBulk -SkipParity -SkipAuth (those phases
exercise bulk read/write CLI subcommands that also live on the
divergent branch — porting those is a follow-up). All five clients
report `closed=true, addedItems=120, eventCount=5` and overall
`success=true`. Per-language unit tests pass:
  - dotnet: 59/59
  - go:     all packages clean
  - rust:   cargo test --workspace clean
  - python: 42/42
  - java:   gradle build SUCCESSFUL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 04:08:15 -04:00
parent a68f0cf222
commit 71d2c39f01
10 changed files with 594 additions and 10 deletions
@@ -16,6 +16,8 @@ public static class MxGatewayClientCli
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private const string BatchEndOfRecord = "__MXGW_BATCH_EOR__";
/// <summary>Runs the CLI synchronously with the given arguments, writing output and errors.</summary>
/// <param name="args">Command-line arguments (command name followed by options).</param>
/// <param name="standardOutput">TextWriter for command output.</param>
@@ -25,7 +27,7 @@ public static class MxGatewayClientCli
TextWriter standardOutput,
TextWriter standardError)
{
return RunAsync(args, standardOutput, standardError)
return RunAsync(args, standardOutput, standardError, clientFactory: null, standardInput: null)
.GetAwaiter()
.GetResult();
}
@@ -35,11 +37,13 @@ public static class MxGatewayClientCli
/// <param name="standardOutput">TextWriter for command output.</param>
/// <param name="standardError">TextWriter for error messages.</param>
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
public static Task<int> RunAsync(
string[] args,
TextWriter standardOutput,
TextWriter standardError,
Func<MxGatewayClientOptions, IMxGatewayCliClient>? clientFactory = null)
Func<MxGatewayClientOptions, IMxGatewayCliClient>? clientFactory = null,
TextReader? standardInput = null)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(standardOutput);
@@ -49,14 +53,17 @@ public static class MxGatewayClientCli
args,
standardOutput,
standardError,
clientFactory ?? CreateDefaultClient);
clientFactory ?? CreateDefaultClient,
standardInput ?? Console.In);
}
private static async Task<int> RunCoreAsync(
string[] args,
TextWriter standardOutput,
TextWriter standardError,
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory)
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory,
TextReader standardInput,
bool forceJsonErrors = false)
{
if (args.Length is 0 || IsHelp(args[0]))
{
@@ -65,6 +72,12 @@ public static class MxGatewayClientCli
}
string command = args[0].ToLowerInvariant();
if (command is "batch")
{
return await RunBatchAsync(standardOutput, clientFactory, standardInput).ConfigureAwait(false);
}
CliArguments arguments = new(args.Skip(1));
try
@@ -125,7 +138,7 @@ public static class MxGatewayClientCli
string? apiKey = arguments.GetOptional("api-key");
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
if (arguments.HasFlag("json"))
if (forceJsonErrors || arguments.HasFlag("json"))
{
standardError.WriteLine(JsonSerializer.Serialize(
new { error = message, type = exception.GetType().Name },
@@ -140,6 +153,82 @@ public static class MxGatewayClientCli
}
}
/// <summary>
/// Runs the CLI in batch mode: reads one command line at a time from
/// <paramref name="standardInput"/>, dispatches it through the normal
/// routing, writes all output to <paramref name="standardOutput"/>, and
/// then appends <see cref="BatchEndOfRecord"/> as a sentinel so the
/// caller can delimit command results. Continues on failure; errors are
/// written as JSON to <paramref name="standardOutput"/> (not stderr) so
/// that the harness sees them inside the same delimited block. Exits 0
/// on EOF or empty line.
/// </summary>
private static async Task<int> RunBatchAsync(
TextWriter standardOutput,
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory,
TextReader standardInput)
{
while (true)
{
string? line = await standardInput.ReadLineAsync().ConfigureAwait(false);
// EOF or empty line signals clean exit.
if (line is null || line.Length is 0)
{
return 0;
}
// Split on runs of ASCII whitespace — no quoting support by design.
string[] lineArgs = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
// Per-command output is buffered so we can redirect errors to stdout.
using StringWriter commandOutput = new();
// Errors in batch mode go to stdout (same delimited block), formatted as JSON.
// We use a capturing error writer and re-emit through commandOutput after the
// command returns, so the EOR sentinel always follows the complete result.
using StringWriter commandError = new();
try
{
await RunCoreAsync(
lineArgs,
commandOutput,
commandError,
clientFactory,
standardInput,
forceJsonErrors: true)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Unexpected exception that escaped RunCoreAsync (shouldn't happen, but be safe).
commandError.WriteLine(JsonSerializer.Serialize(
new { error = exception.Message, type = exception.GetType().Name },
JsonOptions));
}
// Write any buffered normal output first.
string commandOutputText = commandOutput.ToString();
if (commandOutputText.Length > 0)
{
standardOutput.Write(commandOutputText);
}
// Then any error output — in batch mode it belongs on stdout so the harness
// sees it inside the delimited record.
string commandErrorText = commandError.ToString();
if (commandErrorText.Length > 0)
{
standardOutput.Write(commandErrorText);
}
// Write the end-of-record sentinel and flush so the harness can unblock.
standardOutput.WriteLine(BatchEndOfRecord);
await standardOutput.FlushAsync().ConfigureAwait(false);
}
}
private static IMxGatewayCliClient CreateDefaultClient(MxGatewayClientOptions options)
{
return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options));
@@ -1032,6 +1121,7 @@ public static class MxGatewayClientCli
private static void WriteUsage(TextWriter writer)
{
writer.WriteLine("mxgw-dotnet batch (reads commands from stdin; writes output + __MXGW_BATCH_EOR__ after each)");
writer.WriteLine("mxgw-dotnet version [--json]");
writer.WriteLine("mxgw-dotnet ping --session-id <id> [--json]");
writer.WriteLine("mxgw-dotnet open-session [--client-name <name>] [--json]");
@@ -368,6 +368,66 @@ public sealed class MxGatewayClientCliTests
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>Fake CLI client for testing.</summary>
private sealed class FakeCliClient : IMxGatewayCliClient
{