e2e: drive each client CLI through one long-lived batch process
The cross-language e2e matrix spawned one CLI process per operation —
~250 per client — paying a process (and, for the Java CLI, a full JVM)
cold-start every time. The Java leg alone ran ~16 minutes.
Each client CLI (dotnet, go, rust, python, java) gains a `batch`
subcommand: a single process that reads one command line from stdin,
runs it through the normal subcommand dispatch, writes the JSON result,
then a line containing exactly `__MXGW_BATCH_EOR__`. A failing command
writes its `{"error":...}` envelope and the loop continues.
run-client-e2e-tests.ps1 now launches one batch process per client and
pings every operation through its stdin/stdout, so startup is paid once
per client. The orchestration and assertions are unchanged; the parity
and auth phases now read the `{"error":...}` envelope instead of a
process exit code.
Full 5-client matrix with -VerifyWrite: ~15 min, down from ~35; the Java
leg dropped from ~16 min to ~2-3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+85
@@ -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;
|
||||
@@ -128,9 +132,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();
|
||||
|
||||
+87
@@ -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;
|
||||
@@ -386,6 +389,90 @@ final class MxGatewayCliTests {
|
||||
assertTrue(output.contains("TestMachine_002.TestChangingInt"), output);
|
||||
}
|
||||
|
||||
// ---- Client.Java-027: batch subcommand ----
|
||||
|
||||
@Test
|
||||
void batchCommandExecutesTwoCommandsAndEmitsEorAfterEach() {
|
||||
String stdin = "version --json\nversion --json\n";
|
||||
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
String out = run.output();
|
||||
// Two EOR sentinels — one per input line.
|
||||
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
|
||||
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
|
||||
assertTrue(firstEor >= 0, "expected at least one EOR sentinel");
|
||||
assertTrue(lastEor > firstEor, "expected two distinct EOR sentinels");
|
||||
// Both results contain version JSON.
|
||||
assertTrue(out.contains("\"clientVersion\""), out);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCommandEmitsEorOnFailedCommand() {
|
||||
// "open-session" without --endpoint / --api-key-env will fail against
|
||||
// the FakeClientFactory (missing required option --session-id for
|
||||
// close-session, for example). Use an unknown subcommand to provoke a
|
||||
// picocli parse error which produces a non-zero exit code without
|
||||
// hitting the gateway.
|
||||
String stdin = "no-such-subcommand\nversion --json\n";
|
||||
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
String out = run.output();
|
||||
// Two EOR sentinels even though the first command failed.
|
||||
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
|
||||
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
|
||||
assertTrue(firstEor >= 0, "expected EOR after failed command");
|
||||
assertTrue(lastEor > firstEor, "expected EOR after second (successful) command");
|
||||
// The second command's result is present.
|
||||
assertTrue(out.contains("\"clientVersion\""), out);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCommandExitsZeroOnEmptyLine() {
|
||||
// An empty line signals EOF-equivalent; loop exits immediately.
|
||||
CliRun run = executeBatch(new FakeClientFactory(), "\n");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCommandExitsZeroOnActualEof() {
|
||||
CliRun run = executeBatch(new FakeClientFactory(), "");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchCommandDoesNotTerminateAfterFailedCommand() {
|
||||
// Three lines: good, bad, good — all three EORs must appear and the
|
||||
// third command must produce its output.
|
||||
String stdin = "version --json\nno-such-subcommand\nversion --json\n";
|
||||
CliRun run = executeBatch(new FakeClientFactory(), stdin);
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
String out = run.output();
|
||||
long eorCount = out.lines()
|
||||
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
|
||||
.count();
|
||||
assertEquals(3, eorCount, "expected exactly 3 EOR sentinels, got: " + eorCount + "\nOutput:\n" + 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();
|
||||
|
||||
Reference in New Issue
Block a user