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
@@ -14,7 +14,11 @@ import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
@@ -116,9 +120,90 @@ public final class MxGatewayCli implements Callable<Integer> {
commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand());
commandLine.addSubcommand("galaxy-discover", new GalaxyDiscoverCommand());
commandLine.addSubcommand("galaxy-watch", new GalaxyWatchCommand());
commandLine.addSubcommand("batch", new BatchCommand(clientFactory));
return commandLine;
}
/** Sentinel written to stdout after every command result in batch mode. */
static final String BATCH_EOR = "__MXGW_BATCH_EOR__";
/**
* Reads one CLI invocation per stdin line, executes each via a fresh
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
* every result. Errors are written as JSON to stdout so the harness
* sees them in the same stream, delimited by the same sentinel. The
* loop never terminates on command failure; only stdin EOF (or an
* empty line) ends the session.
*/
@Command(name = "batch", description = "Reads CLI invocations from stdin and executes them sequentially.")
static final class BatchCommand implements Callable<Integer> {
private final MxGatewayCliClientFactory clientFactory;
@Spec
private CommandSpec spec;
BatchCommand(MxGatewayCliClientFactory clientFactory) {
this.clientFactory = clientFactory;
}
@Override
public Integer call() {
PrintWriter out = spec.commandLine().getOut();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
break;
}
String[] args = line.trim().split("\\s+");
if (args.length == 0 || (args.length == 1 && args[0].isEmpty())) {
continue;
}
StringWriter cmdOut = new StringWriter();
StringWriter cmdErr = new StringWriter();
PrintWriter cmdOutWriter = new PrintWriter(cmdOut, true);
PrintWriter cmdErrWriter = new PrintWriter(cmdErr, true);
try {
CommandLine cmd = commandLine(clientFactory);
cmd.setOut(cmdOutWriter);
cmd.setErr(cmdErrWriter);
int exitCode = cmd.execute(args);
cmdOutWriter.flush();
cmdErrWriter.flush();
String cmdOutput = cmdOut.toString();
if (!cmdOutput.isEmpty()) {
out.print(cmdOutput);
}
if (exitCode != 0) {
// Non-zero exit: emit the stderr content (if any) as a JSON
// error object to stdout so the harness can parse it in the
// same delimited stream.
String errText = cmdErr.toString().trim();
if (errText.isEmpty()) {
errText = "command exited with code " + exitCode;
}
Map<String, Object> errorPayload = new LinkedHashMap<>();
errorPayload.put("error", errText);
errorPayload.put("type", "error");
out.println(jsonObject(errorPayload));
}
} catch (Exception ex) {
Map<String, Object> errorPayload = new LinkedHashMap<>();
errorPayload.put("error", ex.getMessage() != null ? ex.getMessage() : ex.getClass().getName());
errorPayload.put("type", "error");
out.println(jsonObject(errorPayload));
}
out.println(BATCH_EOR);
out.flush();
}
} catch (java.io.IOException ex) {
// Stdin closed unexpectedly — treat as EOF and exit normally.
}
return 0;
}
}
abstract static class GalaxyCommand implements Callable<Integer> {
@Mixin
CommonOptions common = new CommonOptions();
@@ -4,8 +4,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
@@ -141,6 +144,47 @@ final class MxGatewayCliTests {
assertTrue(run.output().contains("\"wasSuccessful\":true"));
}
@Test
void batchCommandExecutesVersionAndEmitsEorMarker() {
CliRun run = executeBatch(new FakeClientFactory(), "version --json\n");
assertEquals(0, run.exitCode());
String out = run.output();
assertTrue(out.contains("\"clientVersion\""), out);
assertTrue(out.contains(MxGatewayCli.BATCH_EOR), out);
}
@Test
void batchCommandEmitsEorAfterFailedCommandAndContinues() {
// An unknown subcommand causes a picocli parse error (non-zero exit).
// The loop must still emit BATCH_EOR for the failure and continue
// processing the subsequent valid command.
CliRun run = executeBatch(new FakeClientFactory(), "no-such-subcommand\nversion --json\n");
assertEquals(0, run.exitCode());
String out = run.output();
long eorCount = out.lines()
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
.count();
assertEquals(2, eorCount, "expected exactly 2 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
assertTrue(out.contains("\"clientVersion\""), out);
}
/**
* Runs the CLI with {@code batch} as the subcommand, using the provided
* string as standard input content. Temporarily replaces {@link System#in}
* for the duration of the call.
*/
private static CliRun executeBatch(MxGatewayCli.MxGatewayCliClientFactory factory, String stdinContent) {
InputStream originalIn = System.in;
try {
System.setIn(new ByteArrayInputStream(stdinContent.getBytes(StandardCharsets.UTF_8)));
return execute(factory, "batch");
} finally {
System.setIn(originalIn);
}
}
private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
StringWriter output = new StringWriter();
StringWriter errors = new StringWriter();