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 (commit6126099) 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 in397d3c5moved 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user