From 0d5b488c11625683159024b0a4ca9013660cab42 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 10:58:04 -0400 Subject: [PATCH] feat(java): add ping + galaxy-browse CLI subcommands and galaxy command aliases - D4: add 'ping' subcommand (MX_COMMAND_KIND_PING / PingCommand{message}), accepting --session-id and optional --message (default "ping"); prints the worker's echoed diagnostic message. - D8-java: add 'galaxy-browse' subcommand over browse()/LazyBrowseNode.expand() and raw BrowseChildren paging for --parent. JSON node shape matches the cross-client surface (flattened object fields + hasChildrenHint + nested children array). - D9-java: make galaxy-test-connection / galaxy-last-deploy the primary names, keeping galaxy-test / galaxy-deploy-time as deprecated picocli aliases. - Tests for ping, galaxy-browse JSON hasChildrenHint key, and alias resolution. - README updated for the new/renamed subcommands. --- clients/java/README.md | 29 +- .../zb/mom/ww/mxgateway/cli/MxGatewayCli.java | 318 +++++++++++++++++- .../ww/mxgateway/cli/MxGatewayCliTests.java | 128 +++++++ 3 files changed, 463 insertions(+), 12 deletions(-) diff --git a/clients/java/README.md b/clients/java/README.md index 21474de..d94d11a 100644 --- a/clients/java/README.md +++ b/clients/java/README.md @@ -115,17 +115,33 @@ try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) { messages directly so callers can read all fields (including the nested `GalaxyAttribute` list) without an extra DTO layer. -The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`, -`galaxy-discover`, and `galaxy-watch`. They take the same `--endpoint`, -`--api-key-env`, `--plaintext`, `--ca-file`, `--server-name-override`, -`--timeout`, and `--json` options as the gateway commands. +The CLI exposes matching subcommands: `galaxy-test-connection`, +`galaxy-last-deploy`, `galaxy-discover`, `galaxy-browse`, and `galaxy-watch`. +The short names `galaxy-test` and `galaxy-deploy-time` remain as deprecated +aliases for `galaxy-test-connection` and `galaxy-last-deploy` so existing +scripts keep working. They take the same `--endpoint`, `--api-key-env`, +`--plaintext`, `--ca-file`, `--server-name-override`, `--timeout`, and `--json` +options as the gateway commands. ```powershell -gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" -gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" +gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-test-connection --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" +gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-last-deploy --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" ``` +`galaxy-browse` walks the hierarchy via `BrowseChildren`. Without `--parent` it +returns the root nodes and eagerly expands `--depth` further levels; with +`--parent ` it returns exactly one level of children for that +parent. The filter flags (`--category-ids`, `--template-contains`, +`--tag-name-glob`, `--alarm-bearing-only`, `--historized-only`, +`--include-attributes`) match `galaxy-discover`. The `--json` node shape is the +cross-client browse surface: the flattened object fields plus a +`hasChildrenHint` flag and a nested `children` array. + +```powershell +gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-browse --depth 1 --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" +``` + ### Browsing lazily For UI trees or OPC UA bridges, use `browseChildrenRaw` to walk one level at a @@ -239,6 +255,7 @@ Run the CLI through Gradle: ```powershell gradle :zb-mom-ww-mxgateway-cli:run --args="version --json" gradle :zb-mom-ww-mxgateway-cli:run --args="open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name java-cli --json" +gradle :zb-mom-ww-mxgateway-cli:run --args="ping --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --message hello --json" gradle :zb-mom-ww-mxgateway-cli:run --args="register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --client-name java-cli --json" gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --server-handle 1 --item TestObject.TestInt --json" gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id --server-handle 1 --item-handle 1 --json" 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 7af8534..f8fe941 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 @@ -1,7 +1,9 @@ package com.zb.mom.ww.mxgateway.cli; +import com.zb.mom.ww.mxgateway.client.BrowseChildrenOptions; import com.zb.mom.ww.mxgateway.client.DeployEventStream; import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient; +import com.zb.mom.ww.mxgateway.client.LazyBrowseNode; import com.zb.mom.ww.mxgateway.client.MxEventStream; import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription; import com.zb.mom.ww.mxgateway.client.MxGatewayClient; @@ -10,6 +12,8 @@ import com.zb.mom.ww.mxgateway.client.MxGatewayClientVersion; import com.zb.mom.ww.mxgateway.client.MxGatewaySecrets; import com.zb.mom.ww.mxgateway.client.MxGatewaySession; import com.zb.mom.ww.mxgateway.client.MxValues; +import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply; +import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent; import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute; import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject; @@ -26,8 +30,10 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Set; import java.util.Map; import java.util.Optional; import java.util.concurrent.ArrayBlockingQueue; @@ -42,11 +48,14 @@ import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage; import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult; import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult; import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest; +import mxaccess_gateway.v1.MxaccessGateway.MxCommand; +import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind; import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply; import mxaccess_gateway.v1.MxaccessGateway.MxEvent; import mxaccess_gateway.v1.MxaccessGateway.MxValue; import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent; import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest; +import mxaccess_gateway.v1.MxaccessGateway.PingCommand; import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest; import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult; import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry; @@ -126,6 +135,7 @@ public final class MxGatewayCli implements Callable { commandLine.addSubcommand("version", new VersionCommand()); commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory)); commandLine.addSubcommand("close-session", new CloseSessionCommand(clientFactory)); + commandLine.addSubcommand("ping", new PingCommandLine(clientFactory)); commandLine.addSubcommand("register", new RegisterCommand(clientFactory)); commandLine.addSubcommand("add-item", new AddItemCommand(clientFactory)); commandLine.addSubcommand("advise", new AdviseCommand(clientFactory)); @@ -142,9 +152,10 @@ public final class MxGatewayCli implements Callable { commandLine.addSubcommand("stream-alarms", new StreamAlarmsCommand(clientFactory)); commandLine.addSubcommand("acknowledge-alarm", new AcknowledgeAlarmCommand(clientFactory)); commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory)); - commandLine.addSubcommand("galaxy-test", new GalaxyTestConnectionCommand()); - commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand()); + commandLine.addSubcommand("galaxy-test-connection", new GalaxyTestConnectionCommand()); + commandLine.addSubcommand("galaxy-last-deploy", new GalaxyDeployTimeCommand()); commandLine.addSubcommand("galaxy-discover", new GalaxyDiscoverCommand()); + commandLine.addSubcommand("galaxy-browse", new GalaxyBrowseCommand()); commandLine.addSubcommand("galaxy-watch", new GalaxyWatchCommand()); commandLine.addSubcommand("batch", new BatchCommand(clientFactory)); return commandLine; @@ -359,7 +370,10 @@ public final class MxGatewayCli implements Callable { } } - @Command(name = "galaxy-test", description = "Calls GalaxyRepository.TestConnection.") + @Command( + name = "galaxy-test-connection", + aliases = {"galaxy-test"}, + description = "Calls GalaxyRepository.TestConnection.") static final class GalaxyTestConnectionCommand extends GalaxyCommand { @Override public Integer call() { @@ -368,7 +382,7 @@ public final class MxGatewayCli implements Callable { PrintWriter out = common.spec.commandLine().getOut(); if (json) { Map output = new LinkedHashMap<>(); - output.put("command", "galaxy-test"); + output.put("command", "galaxy-test-connection"); output.put("options", common.redactedJsonMap()); output.put("ok", ok); out.println(jsonObject(output)); @@ -380,7 +394,10 @@ public final class MxGatewayCli implements Callable { } } - @Command(name = "galaxy-deploy-time", description = "Calls GalaxyRepository.GetLastDeployTime.") + @Command( + name = "galaxy-last-deploy", + aliases = {"galaxy-deploy-time"}, + description = "Calls GalaxyRepository.GetLastDeployTime.") static final class GalaxyDeployTimeCommand extends GalaxyCommand { @Override public Integer call() { @@ -389,7 +406,7 @@ public final class MxGatewayCli implements Callable { PrintWriter out = common.spec.commandLine().getOut(); if (json) { Map output = new LinkedHashMap<>(); - output.put("command", "galaxy-deploy-time"); + output.put("command", "galaxy-last-deploy"); output.put("options", common.redactedJsonMap()); output.put("present", result.isPresent()); output.put("timeOfLastDeploy", result.map(Instant::toString).orElse("")); @@ -429,6 +446,260 @@ public final class MxGatewayCli implements Callable { } } + /** + * Page size used for the raw {@code BrowseChildren} paging loop driven by + * the {@code --parent} one-level path. Mirrors {@code BROWSE_CHILDREN_PAGE_SIZE} + * in the client library's lazy-browse helper and the other clients' CLI page + * size so paging behaviour is consistent across languages. + */ + private static final int BROWSE_CHILDREN_CLI_PAGE_SIZE = 500; + + @Command( + name = "galaxy-browse", + description = "Browses the Galaxy hierarchy via GalaxyRepository.BrowseChildren.") + static final class GalaxyBrowseCommand extends GalaxyCommand { + @Option( + names = "--parent", + defaultValue = "-1", + description = "Parent gobject id to browse one level of children for; omit to walk the roots.") + int parent; + + @Option( + names = "--depth", + defaultValue = "0", + description = "When walking roots, eagerly expand this many further levels before printing.") + int depth; + + @Option(names = "--category-ids", description = "Comma-separated category ids to include.") + String categoryIds; + + @Option(names = "--template-contains", description = "Comma-separated template names each child's chain must contain.") + String templateContains; + + @Option(names = "--tag-name-glob", description = "SQL-LIKE-style glob applied to tag_name.") + String tagNameGlob; + + @Option(names = "--alarm-bearing-only", description = "Restrict to alarm-bearing objects.") + boolean alarmBearingOnly; + + @Option(names = "--historized-only", description = "Restrict to objects with at least one historized attribute.") + boolean historizedOnly; + + @Option(names = "--include-attributes", description = "Request attribute population on each returned object.") + boolean includeAttributes; + + @Override + public Integer call() { + if (depth < 0) { + throw new IllegalArgumentException("--depth must be non-negative"); + } + BrowseChildrenOptions options = buildOptions(); + PrintWriter out = common.spec.commandLine().getOut(); + PrintWriter err = common.spec.commandLine().getErr(); + try (GalaxyRepositoryClient client = connect()) { + if (parent >= 0) { + if (depth > 0) { + err.println("warning: --depth is ignored when --parent is specified."); + } + List children = browseOneLevel(client, parent, options); + if (json) { + List> nodes = new ArrayList<>(children.size()); + for (BrowseChild child : children) { + nodes.add(browseNodeMap(child.object(), child.hasChildrenHint(), List.of())); + } + Map output = new LinkedHashMap<>(); + output.put("command", "galaxy-browse"); + output.put("options", common.redactedJsonMap()); + output.put("parentId", parent); + output.put("nodes", nodes); + out.println(jsonObject(output)); + } else { + out.println(children.size()); + for (BrowseChild child : children) { + printBrowseChild(out, child); + } + } + return 0; + } + + List roots = client.browse(options); + for (LazyBrowseNode root : roots) { + expandToDepth(root, depth); + } + if (json) { + List> nodes = new ArrayList<>(roots.size()); + for (LazyBrowseNode root : roots) { + nodes.add(lazyNodeMap(root)); + } + Map output = new LinkedHashMap<>(); + output.put("command", "galaxy-browse"); + output.put("options", common.redactedJsonMap()); + output.put("nodes", nodes); + out.println(jsonObject(output)); + } else { + out.println(roots.size()); + for (LazyBrowseNode root : roots) { + printLazyNode(out, root, 0); + } + } + } + return 0; + } + + private BrowseChildrenOptions buildOptions() { + return BrowseChildrenOptions.builder() + .categoryIds(parseOptionalIntList(categoryIds)) + .templateChainContains(parseOptionalStringList(templateContains)) + .tagNameGlob(tagNameGlob == null ? "" : tagNameGlob) + // Tri-state: only override the server default when the flag is present. + .includeAttributes(includeAttributes ? Boolean.TRUE : null) + .alarmBearingOnly(alarmBearingOnly) + .historizedOnly(historizedOnly) + .build(); + } + } + + /** One raw {@code BrowseChildren} child paired with its server-supplied has-children hint. */ + private record BrowseChild(GalaxyObject object, boolean hasChildrenHint) { + } + + /** + * Drives the raw {@code BrowseChildren} paging loop for a single parent and + * returns the flattened one-level child list. Used by the {@code --parent} + * path, which surfaces a single level rather than the lazy root-tree walk. + */ + private static List browseOneLevel( + GalaxyRepositoryClient client, int parentGobjectId, BrowseChildrenOptions options) { + List children = new ArrayList<>(); + Set seenPageTokens = new HashSet<>(); + String pageToken = ""; + while (true) { + BrowseChildrenRequest.Builder builder = BrowseChildrenRequest.newBuilder() + .setPageSize(BROWSE_CHILDREN_CLI_PAGE_SIZE) + .setPageToken(pageToken) + .setParentGobjectId(parentGobjectId) + .setAlarmBearingOnly(options.isAlarmBearingOnly()) + .setHistorizedOnly(options.isHistorizedOnly()); + if (!options.getCategoryIds().isEmpty()) { + builder.addAllCategoryIds(options.getCategoryIds()); + } + if (!options.getTemplateChainContains().isEmpty()) { + builder.addAllTemplateChainContains(options.getTemplateChainContains()); + } + if (!options.getTagNameGlob().isEmpty()) { + builder.setTagNameGlob(options.getTagNameGlob()); + } + if (options.getIncludeAttributes() != null) { + builder.setIncludeAttributes(options.getIncludeAttributes()); + } + + BrowseChildrenReply reply = client.browseChildrenRaw(builder.build()); + for (int i = 0; i < reply.getChildrenCount(); i++) { + boolean hint = i < reply.getChildHasChildrenCount() && reply.getChildHasChildren(i); + children.add(new BrowseChild(reply.getChildren(i), hint)); + } + + pageToken = reply.getNextPageToken(); + if (pageToken == null || pageToken.isEmpty()) { + return children; + } + if (!seenPageTokens.add(pageToken)) { + throw new IllegalStateException( + "galaxy browse children returned repeated page token: " + pageToken); + } + } + } + + /** + * Recursively expands a {@link LazyBrowseNode} up to {@code depth} further + * levels. A {@code depth} of 0 leaves the node unexpanded so callers print + * only the requested level. Nodes the server reports as childless are not + * expanded. + */ + private static void expandToDepth(LazyBrowseNode node, int depth) { + if (depth <= 0) { + return; + } + if (node.hasChildrenHint()) { + node.expand(); + } + for (LazyBrowseNode child : node.getChildren()) { + expandToDepth(child, depth - 1); + } + } + + /** + * Renders one {@link LazyBrowseNode} (and any already-expanded descendants) + * as a JSON map. Mirrors the {@code galaxy-discover} object shape with an + * added {@code hasChildrenHint} flag and a nested {@code children} array, + * matching the cross-client browse JSON surface. + */ + private static Map lazyNodeMap(LazyBrowseNode node) { + List> children = new ArrayList<>(); + if (node.isExpanded()) { + for (LazyBrowseNode child : node.getChildren()) { + children.add(lazyNodeMap(child)); + } + } + return browseNodeMap(node.getObject(), node.hasChildrenHint(), children); + } + + /** + * Builds the per-node browse JSON map: the flattened Galaxy object fields, + * the {@code hasChildrenHint} flag, and a nested {@code children} array. + * The {@code hasChildrenHint} key is the cross-client standard (Rust / + * Python / .NET / Go all use the same key and node shape). + */ + static Map browseNodeMap( + GalaxyObject object, boolean hasChildrenHint, List> children) { + Map values = galaxyObjectMap(object); + values.put("hasChildrenHint", hasChildrenHint); + values.put("children", children); + return values; + } + + private static void printLazyNode(PrintWriter out, LazyBrowseNode node, int level) { + GalaxyObject obj = node.getObject(); + out.printf( + "%s%d\t%s\t%s\t(attrs=%d, hasChildrenHint=%b)%n", + " ".repeat(level), + obj.getGobjectId(), + obj.getTagName(), + obj.getBrowseName(), + obj.getAttributesCount(), + node.hasChildrenHint()); + if (node.isExpanded()) { + for (LazyBrowseNode child : node.getChildren()) { + printLazyNode(out, child, level + 1); + } + } + } + + private static void printBrowseChild(PrintWriter out, BrowseChild child) { + GalaxyObject obj = child.object(); + out.printf( + "%d\t%s\t%s\t(attrs=%d, hasChildrenHint=%b)%n", + obj.getGobjectId(), + obj.getTagName(), + obj.getBrowseName(), + obj.getAttributesCount(), + child.hasChildrenHint()); + } + + private static List parseOptionalIntList(String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + return parseIntList(value); + } + + private static List parseOptionalStringList(String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + return parseStringList(value); + } + @Command( name = "galaxy-watch", description = "Streams GalaxyRepository.WatchDeployEvents until cancelled.") @@ -622,6 +893,31 @@ public final class MxGatewayCli implements Callable { } } + @Command(name = "ping", description = "Sends a diagnostic ping command to the session worker.") + static final class PingCommandLine extends GatewayCommand { + @Option(names = "--session-id", required = true, description = "Gateway session id.") + String sessionId; + + @Option(names = "--message", defaultValue = "ping", description = "Message echoed back in the reply.") + String message; + + PingCommandLine(MxGatewayCliClientFactory clientFactory) { + super(clientFactory); + } + + @Override + public Integer call() { + try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) { + MxCommandReply reply = client.session(sessionId).pingRaw(message); + // The worker echoes the message in the diagnostic message field; + // there is no dedicated ping reply payload, so the plain-text path + // surfaces that field. + writeOutput("ping", common, json, reply, reply::getDiagnosticMessage); + } + return 0; + } + } + @Command(name = "register", description = "Invokes MXAccess Register.") static final class RegisterCommand extends GatewayCommand { @Option(names = "--session-id", required = true, description = "Gateway session id.") @@ -1438,6 +1734,8 @@ public final class MxGatewayCli implements Callable { } interface MxGatewayCliSession { + MxCommandReply pingRaw(String message); + int register(String clientName); MxCommandReply registerRaw(String clientName); @@ -1523,6 +1821,14 @@ public final class MxGatewayCli implements Callable { } record GrpcMxGatewayCliSession(MxGatewaySession session) implements MxGatewayCliSession { + @Override + public MxCommandReply pingRaw(String message) { + return session.invokeCommand(MxCommand.newBuilder() + .setKind(MxCommandKind.MX_COMMAND_KIND_PING) + .setPing(PingCommand.newBuilder().setMessage(message)) + .build()); + } + @Override public int register(String clientName) { return session.register(clientName); 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 9dc9038..d87094c 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 @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription; import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions; +import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject; import io.grpc.stub.StreamObserver; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -15,6 +16,7 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply; import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest; import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot; @@ -124,6 +126,119 @@ final class MxGatewayCliTests { assertTrue(run.output().contains("\"itemHandle\":7")); } + // ---- ping subcommand (D4) ---- + + @Test + void pingCommandForwardsMessageAndPrintsEcho() { + FakeClientFactory factory = new FakeClientFactory(); + CliRun run = execute( + factory, "ping", "--session-id", "session-cli", "--message", "hello-mxgw"); + + assertEquals(0, run.exitCode()); + assertEquals("hello-mxgw", factory.client.session.lastPingMessage); + // The worker echoes the message in the diagnostic message field; the + // plain-text path surfaces exactly that echoed value. + assertEquals("hello-mxgw", run.output().trim()); + } + + @Test + void pingCommandDefaultsMessageToPing() { + FakeClientFactory factory = new FakeClientFactory(); + CliRun run = execute(factory, "ping", "--session-id", "session-cli"); + + assertEquals(0, run.exitCode()); + assertEquals("ping", factory.client.session.lastPingMessage); + } + + @Test + void pingCommandJsonIncludesPingKindAndDiagnosticMessage() { + FakeClientFactory factory = new FakeClientFactory(); + CliRun run = execute( + factory, "ping", "--session-id", "session-cli", "--message", "diag-1", "--json"); + + assertEquals(0, run.exitCode()); + String out = run.output(); + assertTrue(out.contains("\"command\":\"ping\""), out); + assertTrue(out.contains("\"kind\":\"MX_COMMAND_KIND_PING\""), out); + assertTrue(out.contains("diag-1"), out); + } + + // ---- galaxy-browse subcommand (D8-java) ---- + + @Test + void galaxyBrowseNodeJsonUsesHasChildrenHintKeyAndFlattensObjectFields() { + GalaxyObject object = GalaxyObject.newBuilder() + .setGobjectId(101) + .setTagName("Area001") + .setBrowseName("Area001") + .build(); + Map leaf = MxGatewayCli.browseNodeMap( + GalaxyObject.newBuilder().setGobjectId(202).setTagName("Pump001").build(), + false, + List.of()); + Map node = MxGatewayCli.browseNodeMap(object, true, List.of(leaf)); + + // Cross-client JSON parity: the per-node "has children" flag MUST use the + // key hasChildrenHint (Rust / Python / .NET / Go all standardized on it). + assertTrue(node.containsKey("hasChildrenHint"), node.toString()); + assertEquals(Boolean.TRUE, node.get("hasChildrenHint")); + // Object fields are flattened directly into the node (matching the + // galaxy-discover object shape), not nested under an "object" key. + assertFalse(node.containsKey("object"), node.toString()); + assertEquals(101L, ((Number) node.get("gobjectId")).longValue()); + assertEquals("Area001", node.get("tagName")); + // Nested children array carries the same node shape recursively. + @SuppressWarnings("unchecked") + List> children = (List>) node.get("children"); + assertEquals(1, children.size()); + assertTrue(children.get(0).containsKey("hasChildrenHint")); + assertEquals(Boolean.FALSE, children.get(0).get("hasChildrenHint")); + } + + @Test + void galaxyBrowseInvocationsParseCleanly() { + // galaxy-browse connects via GalaxyRepositoryClient.connect (a static), + // so the full surface is exercised only by the cross-language matrix + // against a live gateway. Here we assert the option surface parses. + assertReadmeExampleParses(new String[] {"galaxy-browse", "--json"}); + assertReadmeExampleParses(new String[] {"galaxy-browse", "--parent", "42", "--json"}); + assertReadmeExampleParses(new String[] { + "galaxy-browse", + "--depth", "2", + "--category-ids", "1,2", + "--template-contains", "$Pump", + "--tag-name-glob", "Area%", + "--alarm-bearing-only", + "--historized-only", + "--include-attributes", + "--json" + }); + } + + // ---- galaxy command-name aliases (D9-java) ---- + + @Test + void galaxyTestConnectionCanonicalAndDeprecatedAliasResolve() { + picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory()); + // Both the canonical dash-separated name and the deprecated short alias + // must resolve to the same subcommand so existing scripts keep working. + assertTrue(commandLine.getSubcommands().containsKey("galaxy-test-connection")); + assertTrue(commandLine.getSubcommands().containsKey("galaxy-test")); + assertEquals( + commandLine.getSubcommands().get("galaxy-test-connection"), + commandLine.getSubcommands().get("galaxy-test")); + } + + @Test + void galaxyLastDeployCanonicalAndDeprecatedAliasResolve() { + picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory()); + assertTrue(commandLine.getSubcommands().containsKey("galaxy-last-deploy")); + assertTrue(commandLine.getSubcommands().containsKey("galaxy-deploy-time")); + assertEquals( + commandLine.getSubcommands().get("galaxy-last-deploy"), + commandLine.getSubcommands().get("galaxy-deploy-time")); + } + @Test void subscribeBulkCommandPrintsResults() { CliRun run = execute( @@ -652,6 +767,19 @@ final class MxGatewayCliTests { private boolean addItemCalled; private boolean adviseCalled; private MxValue lastWriteValue; + private String lastPingMessage; + + @Override + public MxCommandReply pingRaw(String message) { + lastPingMessage = message; + // The worker echoes the request message in the diagnostic message + // field; there is no dedicated ping reply payload. + return MxCommandReply.newBuilder() + .setKind(MxCommandKind.MX_COMMAND_KIND_PING) + .setProtocolStatus(ok()) + .setDiagnosticMessage(message) + .build(); + } @Override public int register(String clientName) {