This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 9fb9bc911f78 CAMEL-23688: camel-jbang - Tests for the runtime tools of
camel ask (#27982)
9fb9bc911f78 is described below
commit 9fb9bc911f7850994166bd23a81838dfda1c568b
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 17 01:03:09 2026 +0200
CAMEL-23688: camel-jbang - Tests for the runtime tools of camel ask (#27982)
Tests for the runtime tools of ToolRegistry (48% covered), which camel ask
and the MCP servers give an AI, against an integration that answers their
actions (FakeIntegration, shared with the eval tests): what each tool asks
the integration for (the source, dump, structure, topology, tracing,
browse, spans, heap dump, SQL, route control, messages), the sections of
the status, the circuit breakers, the errors and history, and the
analysis tools. They found these bugs, fixed here:
* send_message (camel ask) and tui_send_message (camel tui --mcp) put
the headers, given as key=value lines as their description says, in
the action as one text; the send action of the CLI connector reads a
list of keys and values, so the message failed. RuntimeHelper sends the
list now, and a line that is not key=value is an error of the tool.
* get_circuit_breakers left out the calls of the fallback, the calls
that timed out and the calls a full bulkhead rejected, which camel get
circuit-breaker shows.
* get_route_analysis, get_eip_stats and detect_config_drift put the
answers of the integration in their answer as JSON text (escaped),
instead of as JSON.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 10 +
.../dsl/jbang/core/commands/ai/ToolRegistry.java | 28 +-
.../camel/dsl/jbang/core/common/RuntimeHelper.java | 34 ++-
.../core/commands/ai/EvalInTheIntegrationTest.java | 54 +---
.../jbang/core/commands/ai/FakeIntegration.java | 107 +++++++
.../jbang/core/commands/ai/RuntimeToolsTest.java | 334 +++++++++++++++++++++
6 files changed, 515 insertions(+), 52 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 945722646904..0ecfc40763c7 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -654,6 +654,16 @@ for an integration before Camel 4.21. `camel debug` sends
its commands this way
body as they are. They escaped them, which the integration does not undo: an
expression with a double quote failed,
such as `+${body} == "hello"+`, and a slash (as in every XPath) or a quote in
the body became part of the text.
+=== camel-jbang - the runtime tools of camel ask and the MCP servers
+
+* `send_message` of `camel ask`, and `tui_send_message` of `camel tui --mcp`,
send the headers given as `key=value`
+ lines, as their description says: the integration got the lines as one text,
which it could not read as headers, and
+ failed the message. A line that is not `key=value` is an error of the tool.
+* `get_circuit_breakers` has the calls of the fallback, the calls that timed
out and the calls a full bulkhead
+ rejected, as `camel get circuit-breaker` shows them.
+* `get_route_analysis`, `get_eip_stats` and `detect_config_drift` have the
answers of the integration as JSON, instead
+ of as JSON text inside their JSON.
+
=== camel-jpa, camel-hazelcast - an exchange marked rollback only is not
consumed
When a route marks the exchange rollback only (for example with
`markRollbackOnly()` or `markRollbackOnlyLast()`)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
index 0fd09466cf3c..308b9779f734 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java
@@ -690,6 +690,12 @@ public final class ToolRegistry {
entry.put("failedCalls", bj.get("failedCalls"));
entry.put("notPermittedCalls", bj.get("notPermittedCalls"));
entry.put("failureRate", bj.get("failureRate"));
+ // as camel get circuit-breaker shows them, when the breaker
counts them
+ for (String key : new String[] { "fallbackCalls",
"timedOutCalls", "bulkheadRejectedCalls" }) {
+ if (bj.get(key) != null) {
+ entry.put(key, bj.get(key));
+ }
+ }
target.add(entry);
}
}
@@ -715,8 +721,8 @@ public final class ToolRegistry {
root.put("format", "yaml");
});
JsonObject response = new JsonObject();
- response.put("routeStructure", structureJson);
- response.put("routeDump", dumpJson);
+ response.put("routeStructure", json(structureJson));
+ response.put("routeDump", json(dumpJson));
response.put("analysisHints", buildAnalysisHints());
return response.toJson();
}));
@@ -733,8 +739,8 @@ public final class ToolRegistry {
String structureJson = ctx.executeAction("route-structure",
root -> root.put("id", "*"));
JsonObject response = new JsonObject();
- response.put("processorStats", topJson);
- response.put("routeStructures", structureJson);
+ response.put("processorStats", json(topJson));
+ response.put("routeStructures", json(structureJson));
return response.toJson();
}));
@@ -759,8 +765,8 @@ public final class ToolRegistry {
String sourceFiles = ctx.executeAction("source",
root -> root.put("filter", "*"));
JsonObject response = new JsonObject();
- response.put("runningRoutes", runningRoutes);
- response.put("sourceFiles", sourceFiles);
+ response.put("runningRoutes", json(runningRoutes));
+ response.put("sourceFiles", json(sourceFiles));
response.put("format", dumpFormat);
response.put("hint",
"Compare the running route definitions with the
source files to identify any differences. "
@@ -770,6 +776,14 @@ public final class ToolRegistry {
}));
}
+ /**
+ * The answer of the integration in an answer of a tool: as JSON when it
is, not as JSON text in the JSON.
+ */
+ private static Object json(String answer) {
+ Object parsed = Jsoner.deserialize(answer, (JsonObject) null);
+ return parsed != null ? parsed : answer;
+ }
+
private static JsonObject buildAnalysisHints() {
JsonObject hints = new JsonObject();
hints.put("errorHandling",
@@ -1374,7 +1388,7 @@ public final class ToolRegistry {
}
JsonArray steps = new JsonArray();
Object traces = history.get("traces");
- if (traces instanceof java.util.List<?> list) {
+ if (traces instanceof List<?> list) {
for (Object o : list) {
if (!(o instanceof JsonObject t)) {
continue;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
index 11c819f8ef67..e224a882f511 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/RuntimeHelper.java
@@ -23,6 +23,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
+import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -194,14 +195,21 @@ public final class RuntimeHelper {
return readStatusFromFile(historyFile);
}
+ /**
+ * Sends a message to an endpoint of the integration.
+ *
+ * @param headers the headers as key=value pairs
separated by newlines, or null
+ * @throws IllegalArgumentException when a header is not a key=value pair
+ */
public static JsonObject sendMessage(long pid, String endpoint, String
body, String headers) {
+ JsonArray headerList = headers != null ? parseHeaders(headers) : null;
String result = executeAction(pid, "send", root -> {
root.put("endpoint", endpoint);
if (body != null) {
root.put("body", body);
}
- if (headers != null) {
- root.put("headers", headers);
+ if (headerList != null) {
+ root.put("headers", headerList);
}
});
try {
@@ -213,6 +221,28 @@ public final class RuntimeHelper {
}
}
+ /**
+ * The headers as the send action of the CLI connector reads them: a key
and a value for each header.
+ */
+ static JsonArray parseHeaders(String headers) {
+ JsonArray list = new JsonArray();
+ for (String line : headers.split("\\R")) {
+ String header = line.strip();
+ if (header.isEmpty()) {
+ continue;
+ }
+ int pos = header.indexOf('=');
+ if (pos <= 0) {
+ throw new IllegalArgumentException("Header must be in
key=value format, was: " + header);
+ }
+ JsonObject jo = new JsonObject();
+ jo.put("key", header.substring(0, pos).strip());
+ jo.put("value", header.substring(pos + 1));
+ list.add(jo);
+ }
+ return list;
+ }
+
public static JsonObject executeRowUpdate(
long pid, String table, String datasource, String pkValuesJson,
String colValuesJson) {
String result = executeAction(pid, "sql-update-row", root -> {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
index 0e35215168f6..a6b8e4733cd9 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
@@ -16,19 +16,14 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
-import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
import java.util.Map;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.stream.Stream;
import org.apache.camel.console.DevConsole;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.impl.DefaultCamelContext;
import org.apache.camel.impl.console.EvalLanguageDevConsole;
-import org.apache.camel.util.json.DeserializationException;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
import org.junit.jupiter.api.AfterEach;
@@ -53,8 +48,7 @@ class EvalInTheIntegrationTest {
private Path previousHome;
private DefaultCamelContext context;
private EvalLanguageDevConsole console;
- private final AtomicBoolean running = new AtomicBoolean(true);
- private Thread integration;
+ private FakeIntegration integration;
@BeforeEach
void start() throws Exception {
@@ -66,15 +60,12 @@ class EvalInTheIntegrationTest {
console = new EvalLanguageDevConsole();
console.setCamelContext(context);
console.start();
- integration = new Thread(this::answerActions, "test-integration");
- integration.setDaemon(true);
- integration.start();
+ integration = new FakeIntegration(PID, this::eval);
}
@AfterEach
void stop() throws Exception {
- running.set(false);
- integration.join(5000);
+ integration.close();
context.close();
CommandLineHelper.useHomeDir(previousHome.toString());
}
@@ -114,36 +105,13 @@ class EvalInTheIntegrationTest {
}
// the eval action of the CLI connector: the values of the action as they
are, to the eval-language console
- private void answerActions() {
- Path dir = CommandLineHelper.getCamelDir();
- while (running.get()) {
- try (Stream<Path> files = Files.list(dir)) {
- for (Path action : files.filter(f ->
f.getFileName().toString().startsWith(PID + "-action-")).toList()) {
- String text = Files.readString(action);
- if (text.isBlank()) {
- continue;
- }
- JsonObject root = (JsonObject) Jsoner.deserialize(text);
- Object predicate = root.get("predicate");
- Object result = console.call(DevConsole.MediaType.JSON,
Map.of(
- "language", root.getStringOrDefault("language",
"simple"),
- "predicate", predicate != null &&
Boolean.parseBoolean(predicate.toString()),
- "template", root.getStringOrDefault("template",
""),
- "body", root.getStringOrDefault("body", ""),
- "headers", Map.of(), "variables", Map.of()));
- Path output =
dir.resolve(action.getFileName().toString().replace("-action-", "-output-"));
- Path temporary = dir.resolve(PID + "-answer.tmp");
- Files.writeString(temporary, Jsoner.serialize(result));
- Files.move(temporary, output,
StandardCopyOption.ATOMIC_MOVE);
- Files.deleteIfExists(action);
- }
- Thread.sleep(10);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- return;
- } catch (IOException | DeserializationException | RuntimeException
e) {
- // a half written action: the next look reads it
- }
- }
+ private Object eval(JsonObject root) {
+ Object predicate = root.get("predicate");
+ return console.call(DevConsole.MediaType.JSON, Map.of(
+ "language", root.getStringOrDefault("language", "simple"),
+ "predicate", predicate != null &&
Boolean.parseBoolean(predicate.toString()),
+ "template", root.getStringOrDefault("template", ""),
+ "body", root.getStringOrDefault("body", ""),
+ "headers", Map.of(), "variables", Map.of()));
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/FakeIntegration.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/FakeIntegration.java
new file mode 100644
index 000000000000..f9c17099795d
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/FakeIntegration.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.ai;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.util.json.DeserializationException;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * An integration for the tests of the tools: it answers the actions the CLI
writes in the Camel directory, as the CLI
+ * connector does, with what the test gives for the action, and keeps the
actions it took.
+ */
+final class FakeIntegration implements AutoCloseable {
+
+ private final long pid;
+ private final Function<JsonObject, Object> answers;
+ private final List<JsonObject> actions = new CopyOnWriteArrayList<>();
+ private final AtomicBoolean running = new AtomicBoolean(true);
+ private final Thread thread;
+
+ /**
+ * @param pid the pid of the integration, which the tools select
+ * @param answers the answer to an action (as the CLI connector writes
it), or null for none
+ */
+ FakeIntegration(long pid, Function<JsonObject, Object> answers) {
+ this.pid = pid;
+ this.answers = answers;
+ thread = new Thread(this::answerActions, "test-integration");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ /**
+ * The actions the integration took, in order.
+ */
+ List<JsonObject> actions() {
+ return actions;
+ }
+
+ /**
+ * The last action the integration took.
+ */
+ JsonObject lastAction() {
+ return actions.isEmpty() ? null : actions.get(actions.size() - 1);
+ }
+
+ @Override
+ public void close() throws InterruptedException {
+ running.set(false);
+ thread.join(5000);
+ }
+
+ private void answerActions() {
+ Path dir = CommandLineHelper.getCamelDir();
+ while (running.get()) {
+ try (Stream<Path> files = Files.list(dir)) {
+ for (Path action : files.filter(f ->
f.getFileName().toString().startsWith(pid + "-action-")).toList()) {
+ String text = Files.readString(action);
+ if (text.isBlank()) {
+ continue;
+ }
+ JsonObject root = (JsonObject) Jsoner.deserialize(text);
+ actions.add(root);
+ Object answer = answers.apply(root);
+ if (answer != null) {
+ Path output =
dir.resolve(action.getFileName().toString().replace("-action-", "-output-"));
+ Path temporary = dir.resolve(pid + "-answer.tmp");
+ Files.writeString(temporary, answer instanceof String
s ? s : Jsoner.serialize(answer));
+ Files.move(temporary, output,
StandardCopyOption.ATOMIC_MOVE);
+ }
+ Files.deleteIfExists(action);
+ }
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ } catch (IOException | DeserializationException | RuntimeException
e) {
+ // a half written action: the next look reads it
+ }
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RuntimeToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RuntimeToolsTest.java
new file mode 100644
index 000000000000..af84fc01157d
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/RuntimeToolsTest.java
@@ -0,0 +1,334 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.ai;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.InstanceOfAssertFactories.LIST;
+
+/**
+ * The runtime tools of camel ask and the MCP servers against an integration
that answers their actions: what they ask
+ * the integration for, and what they answer.
+ */
+class RuntimeToolsTest {
+
+ private static final long PID = 4712L;
+
+ @TempDir
+ Path home;
+
+ private Path previousHome;
+ private FakeIntegration integration;
+ // the answer of the integration to each action
+ private final Map<String, Object> answers = new ConcurrentHashMap<>();
+
+ @BeforeEach
+ void start() throws Exception {
+ previousHome = CommandLineHelper.getHomeDir();
+ CommandLineHelper.useHomeDir(home.toString());
+ Files.createDirectories(CommandLineHelper.getCamelDir());
+ integration = new FakeIntegration(PID, action ->
answers.get(action.getString("action")));
+ }
+
+ @AfterEach
+ void stop() throws Exception {
+ integration.close();
+ CommandLineHelper.useHomeDir(previousHome.toString());
+ }
+
+ @Test
+ void theSourceOfTheRoutes() throws Exception {
+ answers.put("source", Map.of("routes", List.of()));
+
+ assertThat(call("get_route_source",
Map.of())).isEqualTo("{\"routes\":[]}");
+
assertThat(integration.lastAction().getString("filter")).isEqualTo("*");
+
+ call("get_route_source", Map.of("filter", "orders*"));
+
assertThat(integration.lastAction().getString("filter")).isEqualTo("orders*");
+ }
+
+ @Test
+ void theDumpOfTheRoutes() throws Exception {
+ answers.put("route-dump", Map.of("routes", List.of()));
+
+ call("get_route_dump", Map.of());
+ assertThat(integration.lastAction().getString("id")).isEqualTo("*");
+
assertThat(integration.lastAction().getString("format")).isEqualTo("yaml");
+
+ call("get_route_dump", Map.of("routeId", "orders", "format", "xml"));
+
assertThat(integration.lastAction().getString("id")).isEqualTo("orders");
+
assertThat(integration.lastAction().getString("format")).isEqualTo("xml");
+ }
+
+ @Test
+ void theStructureAndTheTopProcessorsAndTheTopology() throws Exception {
+ answers.put("route-structure", Map.of("routes", List.of()));
+ answers.put("top-processors", Map.of("processors", List.of()));
+ answers.put("route-topology", Map.of("nodes", List.of()));
+
+ call("get_route_structure", Map.of());
+ assertThat(integration.lastAction().getString("id")).isEqualTo("*");
+ call("get_top_processors", Map.of());
+
assertThat(integration.lastAction().getString("action")).isEqualTo("top-processors");
+ call("get_route_topology", Map.of());
+
assertThat(integration.lastAction().getString("metric")).isEqualTo("true");
+
assertThat(integration.lastAction().getString("external")).isEqualTo("true");
+ call("get_route_topology", Map.of("metric", "false", "external",
"false"));
+
assertThat(integration.lastAction().getString("metric")).isEqualTo("false");
+ }
+
+ @Test
+ void theTracing() throws Exception {
+ answers.put("trace", Map.of("enabled", true));
+
+ call("trace_control", Map.of("action", "enable"));
+
assertThat(integration.lastAction().getString("enabled")).isEqualTo("true");
+ call("trace_control", Map.of("action", "DISABLE"));
+
assertThat(integration.lastAction().getString("enabled")).isEqualTo("false");
+ call("trace_control", Map.of("action", "dump"));
+
assertThat(integration.lastAction().getString("dump")).isEqualTo("true");
+
+ assertThatThrownBy(() -> call("trace_control", Map.of("action",
"start")))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessage("Unknown trace action: start. Use 'enable',
'disable', or 'dump'.");
+ assertThatThrownBy(() -> call("trace_control", Map.of()))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessage("action is required (enable, disable, dump)");
+ }
+
+ @Test
+ void browsingAnEndpoint() throws Exception {
+ answers.put("browse", Map.of("browse", List.of()));
+
+ call("browse_endpoint", Map.of("endpoint", "seda:orders"));
+
assertThat(integration.lastAction().getString("filter")).isEqualTo("seda:orders");
+ assertThat(integration.lastAction().getInteger("limit")).isEqualTo(50);
+ call("browse_endpoint", Map.of("endpoint", "seda:orders", "limit",
"5"));
+ assertThat(integration.lastAction().getInteger("limit")).isEqualTo(5);
+
+ assertThatThrownBy(() -> call("browse_endpoint", Map.of()))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessage("endpoint is required");
+ }
+
+ @Test
+ void theSpansAndTheStartupStepsAndTheThreads() throws Exception {
+ answers.put("span", Map.of("spans", List.of()));
+ answers.put("startup-recorder", Map.of("steps", List.of()));
+ answers.put("thread-dump", Map.of("threads", List.of()));
+ answers.put("heap-histogram", Map.of("classes", List.of()));
+
+ call("get_spans", Map.of());
+
assertThat(integration.lastAction().getString("limit")).isEqualTo("100");
+ call("get_spans", Map.of("limit", "7"));
+ assertThat(integration.lastAction().getString("limit")).isEqualTo("7");
+ call("get_startup_steps", Map.of());
+
assertThat(integration.lastAction().getString("action")).isEqualTo("startup-recorder");
+ call("get_thread_dump", Map.of());
+
assertThat(integration.lastAction().getString("action")).isEqualTo("thread-dump");
+ call("get_heap_histogram", Map.of());
+
assertThat(integration.lastAction().getString("action")).isEqualTo("heap-histogram");
+ }
+
+ @Test
+ void aHeapDumpWithANameAndOnlyTheLiveObjects() throws Exception {
+ answers.put("heap-dump", Map.of("file", "/tmp/orders.hprof"));
+
+ call("write_heap_dump", Map.of());
+ assertThat(integration.lastAction().containsKey("name")).isFalse();
+ assertThat(integration.lastAction().containsKey("live")).isFalse();
+
+ call("write_heap_dump", Map.of("name", "orders", "live", "false"));
+
assertThat(integration.lastAction().getString("name")).isEqualTo("orders");
+
assertThat(integration.lastAction().getString("live")).isEqualTo("false");
+ }
+
+ @Test
+ void aMessageWithHeaders() throws Exception {
+ answers.put("send", Map.of("status", "success", "elapsed", 5));
+
+ Object answer = call("send_message", Map.of("endpoint",
"direct:orders", "body", "hello",
+ "headers", "id=1\norigin = web\n"));
+
+ assertThat(answer.toString()).contains("\"status\":\"success\"");
+ JsonObject action = integration.lastAction();
+ assertThat(action.getString("endpoint")).isEqualTo("direct:orders");
+ assertThat(action.getString("body")).isEqualTo("hello");
+ // as the send action of the CLI connector reads them: a key and a
value for each header
+ List<JsonObject> headers = action.getCollection("headers");
+ assertThat(headers).hasSize(2);
+ assertThat(headers.get(0)).containsEntry("key",
"id").containsEntry("value", "1");
+ assertThat(headers.get(1)).containsEntry("key",
"origin").containsEntry("value", " web");
+ }
+
+ @Test
+ void aHeaderThatIsNotAKeyAndAValueIsAnError() {
+ answers.put("send", Map.of("status", "success"));
+
+ assertThatThrownBy(() -> call("send_message", Map.of("endpoint",
"direct:orders", "headers", "id")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Header must be in key=value format, was: id");
+ }
+
+ @Test
+ void aSqlQuery() throws Exception {
+ answers.put("sql-query", Map.of("columns", List.of("id"), "rows",
List.of()));
+
+ call("execute_sql", Map.of("query", "select id from orders"));
+ JsonObject action = integration.lastAction();
+ assertThat(action.getString("sql")).isEqualTo("select id from orders");
+ assertThat(action.getInteger("maxRows")).isEqualTo(100);
+ assertThat(action.getInteger("queryTimeout")).isEqualTo(30);
+ assertThat(action.containsKey("datasource")).isFalse();
+
+ call("execute_sql", Map.of("query", "select 1", "datasource",
"ordersDS", "maxRows", "5"));
+
assertThat(integration.lastAction().getString("datasource")).isEqualTo("ordersDS");
+
assertThat(integration.lastAction().getInteger("maxRows")).isEqualTo(5);
+ }
+
+ @Test
+ void controllingARoute() throws Exception {
+ answers.put("route", Map.of("status", "success"));
+
+ assertThat(call("stop_route", Map.of("routeId",
"orders"))).isEqualTo("Route orders: stop done");
+
assertThat(integration.lastAction().getString("id")).isEqualTo("orders");
+
assertThat(integration.lastAction().getString("command")).isEqualTo("stop");
+
+ answers.put("route", Map.of("status", "failed", "error", "No route
with id: payments"));
+ assertThatThrownBy(() -> call("start_route", Map.of("routeId",
"payments")))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageContaining("No route with id: payments");
+ }
+
+ @Test
+ void theCircuitBreakersOfEachKind() throws Exception {
+ JsonObject resilience = new JsonObject(
+ Map.of("routeId", "orders", "id", "cb1", "state", "OPEN",
+ "bufferedCalls", 10, "successfulCalls", 4,
"failedCalls", 6, "notPermittedCalls", 2,
+ "failureRate", 60.0, "fallbackCalls", 8,
"timedOutCalls", 1));
+ JsonObject tolerance = new JsonObject(
+ Map.of("routeId", "payments", "id", "cb2", "state", "CLOSED",
+ "successfulCalls", 3, "bulkheadRejectedCalls", 5));
+ JsonObject core = new JsonObject(Map.of("routeId", "audit", "state",
"HALF_OPEN"));
+ JsonObject status = new JsonObject();
+ status.put("context", new JsonObject(Map.of("name", "myApp")));
+ status.put("resilience4j", new JsonObject(Map.of("circuitBreakers",
new JsonArray(List.of(resilience)))));
+ status.put("fault-tolerance", new JsonObject(Map.of("circuitBreakers",
new JsonArray(List.of(tolerance)))));
+ status.put("circuit-breaker", new JsonObject(Map.of("circuitBreakers",
new JsonArray(List.of(core)))));
+ writeStatus(status);
+
+ JsonObject answer = (JsonObject)
Jsoner.deserialize(call("get_circuit_breakers", Map.of()).toString());
+
+ assertThat(answer.getInteger("count")).isEqualTo(3);
+ List<JsonObject> breakers = answer.getCollection("circuitBreakers");
+ assertThat(breakers.get(0)).containsEntry("component",
"resilience4j").containsEntry("state", "OPEN");
+ // the calls of the fallback, the timeouts and the full bulkhead, as
camel get circuit-breaker shows them
+ assertThat(breakers.get(0).getInteger("fallbackCalls")).isEqualTo(8);
+ assertThat(breakers.get(0).getInteger("timedOutCalls")).isEqualTo(1);
+ assertThat(breakers.get(1)).containsEntry("component",
"fault-tolerance");
+
assertThat(breakers.get(1).getInteger("bulkheadRejectedCalls")).isEqualTo(5);
+ assertThat(breakers.get(2)).containsEntry("component",
"core").containsEntry("state", "HALF_OPEN");
+ assertThat(answer.containsKey("hint")).isFalse();
+ }
+
+ @Test
+ void noCircuitBreakers() throws Exception {
+ writeStatus(new JsonObject(Map.of("context", new
JsonObject(Map.of("name", "myApp")))));
+
+ JsonObject answer = (JsonObject)
Jsoner.deserialize(call("get_circuit_breakers", Map.of()).toString());
+
+ assertThat(answer.getInteger("count")).isZero();
+ assertThat(answer.getString("hint")).startsWith("No circuit breakers
found.");
+ }
+
+ @Test
+ void theSectionsOfTheStatus() throws Exception {
+ JsonObject status = new JsonObject();
+ status.put("micrometer", new JsonObject(Map.of("counters", new
JsonArray())));
+ status.put("dataSources", new JsonObject(Map.of("dataSources", new
JsonArray())));
+ writeStatus(status);
+
+ assertThat(call("get_metrics",
Map.of())).isEqualTo("{\"counters\":[]}");
+ assertThat(call("get_datasources",
Map.of())).isEqualTo("{\"dataSources\":[]}");
+ }
+
+ @Test
+ void theErrorsAndTheHistory() throws Exception {
+ assertThat(call("get_errors", Map.of())).isEqualTo("No errors
captured.");
+ assertThat(call("get_history", Map.of())).isEqualTo("No message
history available.");
+
+ Files.writeString(CommandLineHelper.getCamelDir().resolve(PID +
"-error.json"), "{\"errors\":[]}");
+ assertThat(call("get_errors", Map.of())).isEqualTo("{\"errors\":[]}");
+ }
+
+ @Test
+ void theAnalysisToolsHaveTheAnswersOfTheIntegrationAsJson() throws
Exception {
+ answers.put("route-structure", Map.of("routes",
List.of(Map.of("routeId", "orders"))));
+ answers.put("route-dump", Map.of("routes", List.of(Map.of("routeId",
"orders"))));
+ answers.put("top-processors", Map.of("processors", List.of()));
+ answers.put("source", Map.of("routes", List.of()));
+
+ JsonObject analysis = (JsonObject)
Jsoner.deserialize(call("get_route_analysis", Map.of()).toString());
+ assertThat(object(analysis,
"routeStructure").get("routes")).asInstanceOf(LIST).hasSize(1);
+ assertThat(object(analysis, "routeDump")).containsKey("routes");
+ assertThat(object(analysis,
"analysisHints")).containsKey("errorHandling");
+
+ JsonObject stats = (JsonObject)
Jsoner.deserialize(call("get_eip_stats", Map.of()).toString());
+ assertThat(object(stats, "processorStats")).containsKey("processors");
+ assertThat(object(stats, "routeStructures")).containsKey("routes");
+
+ JsonObject drift = (JsonObject)
Jsoner.deserialize(call("detect_config_drift", Map.of("format",
"xml")).toString());
+ assertThat(object(drift, "runningRoutes")).containsKey("routes");
+ assertThat(object(drift, "sourceFiles")).containsKey("routes");
+ assertThat(drift.getString("format")).isEqualTo("xml");
+ // the dump in the format asked for
+ assertThat(integration.actions().stream().filter(a ->
"route-dump".equals(a.getString("action")))
+ .map(a ->
a.getString("format")).toList()).containsExactly("yaml", "xml");
+ }
+
+ private static Object call(String tool, Map<String, String> args) throws
Exception {
+ ToolContext ctx = new ToolContext();
+ ctx.selectProcess(PID);
+ return ToolRegistry.execute(tool, ctx, new HashMap<>(args));
+ }
+
+ // the value of the key, which must be JSON (not JSON text)
+ private static JsonObject object(JsonObject json, String key) {
+ assertThat(json.get(key)).isInstanceOf(JsonObject.class);
+ return (JsonObject) json.get(key);
+ }
+
+ private static void writeStatus(JsonObject status) throws Exception {
+ Files.writeString(CommandLineHelper.getCamelDir().resolve(PID +
"-status.json"), status.toJson());
+ }
+}