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
+105 -3
View File
@@ -9,6 +9,7 @@
#![warn(missing_docs)]
use std::env;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
@@ -189,6 +190,13 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Read commands from stdin, one per line, execute each in sequence, and
/// write `__MXGW_BATCH_EOR__` to stdout after every result. Errors are
/// written as `{"error":"…","type":"error"}` JSON to stdout (not stderr)
/// so the harness can parse them without interleaving stderr. The loop
/// never terminates on command error; only stdin EOF (or an empty line)
/// ends the session.
Batch,
#[command(subcommand)]
Galaxy(GalaxyCommand),
}
@@ -297,7 +305,11 @@ enum CliValueType {
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli).await {
let result = match cli.command {
Command::Batch => run_batch().await,
command => dispatch(command).await,
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{error}");
@@ -306,8 +318,17 @@ async fn main() -> ExitCode {
}
}
async fn run(cli: Cli) -> Result<(), Error> {
match cli.command {
/// Dispatch a parsed [`Command`] to its handler. All subcommands except
/// [`Command::Batch`] are handled here; `Batch` is handled separately in
/// `main` to avoid mutual recursion between `dispatch` and `run_batch`.
async fn dispatch(command: Command) -> Result<(), Error> {
match command {
Command::Batch => {
return Err(Error::InvalidArgument {
name: "batch".to_owned(),
detail: "batch cannot be nested inside another batch session".to_owned(),
});
}
Command::Version { json, .. } => print_version(json),
Command::Ping {
connection,
@@ -706,6 +727,76 @@ async fn session_for(
Ok(client.session(session_id))
}
/// End-of-result sentinel written to stdout after every batch command.
const BATCH_EOR: &str = "__MXGW_BATCH_EOR__";
/// Run the batch loop: read one command line at a time from stdin, dispatch
/// each through the normal [`dispatch`] path, and write [`BATCH_EOR`] to
/// stdout after every result. Errors are serialised as JSON to stdout so
/// the harness can parse them without interleaving stderr. The loop never
/// terminates on command error; only stdin EOF or an empty line ends the
/// session.
async fn run_batch() -> Result<(), Error> {
let stdin = io::stdin();
let stdout = io::stdout();
for line in stdin.lock().lines() {
let line = line.map_err(|e| Error::InvalidArgument {
name: "stdin".to_owned(),
detail: e.to_string(),
})?;
if line.is_empty() {
break;
}
let parts: Vec<&str> = line.split_ascii_whitespace().collect();
if parts.is_empty() {
println!("{BATCH_EOR}");
stdout.lock().flush().ok();
continue;
}
// Re-parse the split arguments under a fresh Cli, prepending the
// program-name placeholder so clap sees a complete argv[].
let parse_result =
Cli::try_parse_from(std::iter::once("mxgw-cli").chain(parts.iter().copied()));
let outcome: Result<(), Error> = match parse_result {
Ok(cli) => {
// Spawn on a new tokio task so each command runs with a fresh
// stack, avoiding stack overflow from the large dispatch future.
tokio::task::spawn(dispatch(cli.command))
.await
.unwrap_or_else(|join_err| {
Err(Error::InvalidArgument {
name: "task".to_owned(),
detail: join_err.to_string(),
})
})
}
Err(clap_err) => Err(Error::InvalidArgument {
name: "args".to_owned(),
detail: clap_err.to_string(),
}),
};
if let Err(err) = outcome {
// Write error as JSON to stdout so the harness sees it in the
// same stream as normal output, framed by the EOR sentinel.
println!(
"{}",
serde_json::json!({ "error": err.to_string(), "type": "error" })
);
}
println!("{BATCH_EOR}");
stdout.lock().flush().ok();
}
Ok(())
}
fn print_version(use_json: bool) {
if use_json {
println!("{}", version_json());
@@ -1073,6 +1164,17 @@ mod tests {
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_batch_command() {
let parsed = Cli::try_parse_from(["mxgw", "batch"]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn batch_eor_marker_is_stable() {
assert_eq!(super::BATCH_EOR, "__MXGW_BATCH_EOR__");
}
#[test]
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)