diff --git a/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java b/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java index f8fe941..7364ca1 100644 --- a/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java +++ b/clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java @@ -458,16 +458,24 @@ public final class MxGatewayCli implements Callable { name = "galaxy-browse", description = "Browses the Galaxy hierarchy via GalaxyRepository.BrowseChildren.") static final class GalaxyBrowseCommand extends GalaxyCommand { + @Spec + private CommandSpec spec; + @Option( names = "--parent", defaultValue = "-1", - description = "Parent gobject id to browse one level of children for; omit to walk the roots.") + description = + "Parent gobject id to browse one level of children for." + + " Use the default (omit) to walk root nodes;" + + " gobject id 0 is reserved by the server to mean roots.") int parent; @Option( names = "--depth", defaultValue = "0", - description = "When walking roots, eagerly expand this many further levels before printing.") + description = + "When walking roots, eagerly expand this many further levels before printing." + + " Must be between 0 and 50 inclusive.") int depth; @Option(names = "--category-ids", description = "Comma-separated category ids to include.") @@ -491,11 +499,17 @@ public final class MxGatewayCli implements Callable { @Override public Integer call() { if (depth < 0) { - throw new IllegalArgumentException("--depth must be non-negative"); + throw new CommandLine.ParameterException(spec.commandLine(), "--depth must be non-negative"); + } + if (depth > 50) { + throw new CommandLine.ParameterException(spec.commandLine(), "--depth must be at most 50"); } BrowseChildrenOptions options = buildOptions(); PrintWriter out = common.spec.commandLine().getOut(); PrintWriter err = common.spec.commandLine().getErr(); + if (parent == 0) { + err.println("warning: --parent 0 is the server sentinel for root nodes; omit --parent to walk roots instead."); + } try (GalaxyRepositoryClient client = connect()) { if (parent >= 0) { if (depth > 0) { diff --git a/clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java b/clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java index d87094c..1d0222d 100644 --- a/clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java +++ b/clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java @@ -215,6 +215,53 @@ final class MxGatewayCliTests { }); } + @Test + void galaxyBrowseNegativeDepthYieldsNonZeroExitViaParameterException() { + // Fix: --depth validation must surface as a picocli ParameterException + // (clean error line on stderr) rather than an unhandled IllegalArgumentException + // stack trace. Picocli maps ParameterException to exit code 2. + CliRun run = execute(new FakeClientFactory(), "galaxy-browse", "--depth", "-1"); + + assertFalse(run.exitCode() == 0, "expected non-zero exit for --depth -1"); + // Picocli writes ParameterException messages to the error writer. + assertTrue(run.errors().contains("--depth"), "expected --depth in error output: " + run.errors()); + } + + @Test + void galaxyBrowseDepthAbove50YieldsNonZeroExit() { + CliRun run = execute(new FakeClientFactory(), "galaxy-browse", "--depth", "51"); + + assertFalse(run.exitCode() == 0, "expected non-zero exit for --depth 51"); + assertTrue(run.errors().contains("--depth"), "expected --depth in error output: " + run.errors()); + } + + @Test + void galaxyBrowseParentZeroEmitsWarningToStderr() { + // --parent 0 is the server sentinel for roots; passing it explicitly is + // almost certainly a mistake. The CLI must print a warning to stderr + // (matching Go/Rust client behaviour) but must still attempt the call + // (exit behaviour depends on gateway reachability, not tested here; + // we only assert the warning path is triggered by checking the error + // writer before any gRPC connection is attempted). + // + // GalaxyBrowseCommand connects to a real GalaxyRepositoryClient, so the + // call() body will throw after printing the warning when no gateway is + // reachable. We only assert the warning appears on stderr. + StringWriter output = new StringWriter(); + StringWriter errors = new StringWriter(); + // Non-zero exit is expected (no live gateway), but the warning must + // appear on stderr regardless of what happens next. + MxGatewayCli.execute( + new FakeClientFactory(), + new PrintWriter(output, true), + new PrintWriter(errors, true), + "galaxy-browse", "--parent", "0", "--depth", "1"); + + assertTrue( + errors.toString().contains("--parent 0"), + "expected '--parent 0' warning on stderr; got: " + errors); + } + // ---- galaxy command-name aliases (D9-java) ---- @Test diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs index 0796638..99c30da 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs @@ -156,7 +156,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests Assert.Equal(ProtocolStatusCode.Ok, infoReply.ProtocolStatus.Code); Assert.Equal(MxCommandKind.GetWorkerInfo, infoReply.Kind); Assert.NotNull(infoReply.WorkerInfo); - Assert.Equal(ControlCommandFakeWorkerProcessLauncher.ProcessId, infoReply.WorkerInfo.WorkerProcessId); + Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, infoReply.WorkerInfo.WorkerProcessId); Assert.False(string.IsNullOrEmpty(infoReply.WorkerInfo.MxaccessProgid)); // DrainEvents — the scripted worker returns an empty drain reply. @@ -522,9 +522,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests or MxCommandKind.GetWorkerInfo or MxCommandKind.DrainEvents or MxCommandKind.ShutdownWorker) { - // Re-enter the harness to process the already-read envelope - // by replaying it through the control-command responder path. - await RespondToKnownControlCommandAsync(harness, envelope, cancellationToken) + await harness.RespondToControlCommandAsync(envelope, cancellationToken) .ConfigureAwait(false); _commandHandled.Release(); continue; @@ -535,70 +533,6 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests $"ControlCommandFakeWorkerProcessLauncher received unexpected envelope {envelope.BodyCase}."); } } - - private static async Task RespondToKnownControlCommandAsync( - FakeWorkerHarness harness, - WorkerEnvelope commandEnvelope, - CancellationToken cancellationToken) - { - MxCommand command = commandEnvelope.WorkerCommand.Command; - switch (command.Kind) - { - case MxCommandKind.Ping: - await harness.ReplyToCommandAsync( - commandEnvelope, - configureReply: reply => - { - string? message = command.Ping?.Message; - if (!string.IsNullOrEmpty(message)) - { - reply.DiagnosticMessage = message; - } - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - break; - - case MxCommandKind.GetSessionState: - await harness.ReplyToCommandAsync( - commandEnvelope, - configureReply: reply => reply.SessionState = new SessionStateReply - { - State = SessionState.Ready, - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - break; - - case MxCommandKind.GetWorkerInfo: - await harness.ReplyToCommandAsync( - commandEnvelope, - configureReply: reply => reply.WorkerInfo = new WorkerInfoReply - { - WorkerProcessId = ControlCommandFakeWorkerProcessLauncher.ProcessId, - WorkerVersion = "fake-control-worker", - MxaccessProgid = "LMXProxy.LMXProxyServer.1", - MxaccessClsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", - }, - cancellationToken: cancellationToken).ConfigureAwait(false); - break; - - case MxCommandKind.DrainEvents: - await harness.ReplyToCommandAsync( - commandEnvelope, - configureReply: reply => reply.DrainEvents = new DrainEventsReply(), - cancellationToken: cancellationToken).ConfigureAwait(false); - break; - - case MxCommandKind.ShutdownWorker: - await harness.ReplyToCommandAsync(commandEnvelope, cancellationToken: cancellationToken) - .ConfigureAwait(false); - await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - break; - - default: - throw new InvalidOperationException( - $"Unexpected control command kind {command.Kind} in ControlCommandFakeWorkerProcessLauncher."); - } - } } private sealed class FakeWorkerProcess(int processId) : IWorkerProcess diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs index f5a3902..cb6c6d7 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs @@ -207,7 +207,7 @@ public sealed class FakeWorkerHarnessTests CreateCommand(MxCommandKind.Ping, cmd => cmd.Ping = new PingCommand { Message = "hello-ping" }), TestTimeout, CancellationToken.None); - await fakeWorker.RespondToControlCommandAsync(); + await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout); WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); @@ -231,7 +231,7 @@ public sealed class FakeWorkerHarnessTests CreateCommand(MxCommandKind.GetSessionState), TestTimeout, CancellationToken.None); - await fakeWorker.RespondToControlCommandAsync(); + await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout); WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); @@ -256,7 +256,7 @@ public sealed class FakeWorkerHarnessTests CreateCommand(MxCommandKind.GetWorkerInfo), TestTimeout, CancellationToken.None); - await fakeWorker.RespondToControlCommandAsync(); + await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout); WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); @@ -265,7 +265,8 @@ public sealed class FakeWorkerHarnessTests Assert.NotNull(reply.Reply.WorkerInfo); Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, reply.Reply.WorkerInfo.WorkerProcessId); Assert.Equal("LMXProxy.LMXProxyServer.1", reply.Reply.WorkerInfo.MxaccessProgid); - Assert.False(string.IsNullOrEmpty(reply.Reply.WorkerInfo.MxaccessClsid)); + Assert.Equal("{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", reply.Reply.WorkerInfo.MxaccessClsid); + Assert.Equal("fake-worker", reply.Reply.WorkerInfo.WorkerVersion); } /// @@ -283,7 +284,7 @@ public sealed class FakeWorkerHarnessTests CreateCommand(MxCommandKind.DrainEvents, cmd => cmd.DrainEvents = new DrainEventsCommand { MaxEvents = 32 }), TestTimeout, CancellationToken.None); - await fakeWorker.RespondToControlCommandAsync(); + await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout); WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); @@ -314,7 +315,7 @@ public sealed class FakeWorkerHarnessTests // The harness reads the ShutdownWorker WorkerCommand and replies with // OK + ShutdownAck — the WorkerClient's read loop processes the ack and // transitions to Closed. - await fakeWorker.RespondToControlCommandAsync(); + await fakeWorker.RespondToControlCommandAsync().WaitAsync(TestTimeout); WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs index 2277497..6aee6a2 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs @@ -409,6 +409,37 @@ public sealed class FakeWorkerHarness : IAsyncDisposable CancellationToken cancellationToken = default) { WorkerEnvelope commandEnvelope = await ReadCommandAsync(cancellationToken).ConfigureAwait(false); + return await RespondToControlCommandAsync(commandEnvelope, cancellationToken).ConfigureAwait(false); + } + + /// + /// Accepts an already-read command envelope and, if it is one of the five control + /// command kinds (Ping, GetSessionState, GetWorkerInfo, DrainEvents, ShutdownWorker), + /// writes a canned reply that mirrors the real worker's reply shape. For ShutdownWorker + /// the method additionally sends a after the OK reply. + /// Use this overload when the caller has already consumed the envelope from the pipe + /// (e.g., to inspect the kind before routing) to avoid re-reading. + /// + /// The already-read command envelope to respond to. + /// Token to cancel the asynchronous operation. + /// The command envelope that was handled. + /// + /// Thrown when does not contain a WorkerCommand. + /// + /// + /// Thrown when the command kind is not one of the five control command kinds. + /// + public async Task RespondToControlCommandAsync( + WorkerEnvelope commandEnvelope, + CancellationToken cancellationToken = default) + { + if (commandEnvelope.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCommand) + { + throw new ArgumentException( + $"Expected WorkerCommand envelope but received {commandEnvelope.BodyCase}.", + nameof(commandEnvelope)); + } + MxCommand command = commandEnvelope.WorkerCommand.Command; switch (command.Kind)