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 7941461d6afb CAMEL-26765: camel-chaos - see through stub endpoints: 
suggestions for stubbed remote endpoints, and kept consumers (#29458)
7941461d6afb is described below

commit 7941461d6afb65e350970187b1465beff64d4614
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Nov 9 11:27:35 2026 +0100

    CAMEL-26765: camel-chaos - see through stub endpoints: suggestions for 
stubbed remote endpoints, and kept consumers (#29458)
    
    * CAMEL-26765: camel-chaos - see through stub endpoints: suggestions for 
stubbed remote endpoints, and kept consumers
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/docs/chaos.adoc       |  26 ++++-
 components/camel-chaos/pom.xml                     |   6 +
 components/camel-chaos/src/main/docs/chaos.adoc    |  26 ++++-
 .../apache/camel/component/chaos/ChaosAdvisor.java |   4 +-
 .../camel/component/chaos/ChaosConsumers.java      |  39 ++++---
 .../apache/camel/component/chaos/ChaosManager.java |   8 ++
 .../apache/camel/component/chaos/ChaosReport.java  |  12 +-
 .../apache/camel/component/chaos/ChaosStubs.java   |  99 ++++++++++++++++
 .../camel/component/chaos/ChaosStubsTest.java      | 125 +++++++++++++++++++++
 .../org/apache/camel/component/chaos/remotemq.json |  10 ++
 .../services/org/apache/camel/component/remotemq   |  19 ++++
 .../dsl/jbang/core/commands/process/ListChaos.java |  11 +-
 .../jbang/core/commands/process/ListChaosTest.java |  15 +++
 .../dsl/jbang/core/commands/tui/ChaosInfo.java     |   5 +-
 .../dsl/jbang/core/commands/tui/ChaosTab.java      |   6 +-
 .../core/commands/tui/ChaosTabRenderTest.java      |   4 +-
 16 files changed, 390 insertions(+), 25 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
index 62a335f5edf5..8d6bd7a724ae 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/chaos.adoc
@@ -266,6 +266,28 @@ In JSON the report has `tried`: per `route` its `kinds` 
(`kind`, `rules`, `fired
 (`tried`): `camel cmd chaos-suggest` ends its line with `- tried`, and the 
what to try list of the Camel TUI (key `w`)
 shows both.
 
+=== With stubs
+
+`camel run --stub=remote` serves the remote components with the stub 
component, a queue in memory, so the integration
+runs without the systems behind it, and `camel cmd send` feeds a stubbed 
consumer:
+
+[source,bash]
+----
+camel run * --stub=remote --chaos
+camel cmd send --endpoint=kafka:refunds --body=file:samples/refund.json
+----
+
+The rules match a stub by its URI (`endpoint=kafka:orders`, `route=refunds`), 
and chaos counts a stub as the component
+it stands in for:
+
+* What to try takes a stub as remote when the component is. It reads that from 
the JSON of the component, which
+`camel run` downloads with the component for a stub too.
+* A stubbed consumer keeps a failed message by the options of its URI, as the 
component would:
+`kafka:refunds?breakOnFirstError=true` keeps it. Options set on the component 
do not count, as a stub does not take
+them. The stub does not read the message again, though, so the report says per 
route that its consumer is a stub
+(`stubbedConsumer` in the JSON, `refunds (stub of kafka)` in `camel get chaos 
--report`, and `refunds (stub)` on the
+chaos screen of the Camel TUI): a kept message is then not lost, though 
nothing reads it again while the stub runs.
+
 === From an AI agent (MCP)
 
 The Camel MCP server (`camel mcp`, see xref:manual::camel-jbang-mcp.adoc[Camel 
MCP server]) has the same operations
@@ -762,7 +784,9 @@ per exchange too.
 `acknowledgementModeName=CLIENT_ACKNOWLEDGE` (the endpoint option; the number 
set with `setAcknowledgementMode` in Java
 is not seen) and `asyncConsumer=false`; `kafka` with `breakOnFirstError=true`. 
The options are read through the
 configurer of the endpoint, so options set on the component count too. The 
component is known by the scheme of the
-endpoint URI.
+endpoint URI. A stub that stands in for one of them keeps it by the options of 
its URI (see <<_with_stubs,With stubs>>).
+* `stubbedConsumer`: the component the consumer of the route is a stub of 
(`camel run --stub`), such as `kafka`; left
+out when it is not a stub. A stub does not read a kept message again.
 * `handled`: the completed exchanges after the error handler handled a failure 
(the dead letter channel, or
 `onException` with `handled(true)`). A failure handled in a route called with 
`direct` counts as handled in the
 calling routes too.
diff --git a/components/camel-chaos/pom.xml b/components/camel-chaos/pom.xml
index 3a2851d18b34..7409abbbb018 100644
--- a/components/camel-chaos/pom.xml
+++ b/components/camel-chaos/pom.xml
@@ -72,6 +72,12 @@
             <artifactId>camel-file</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <!-- a stub that stands in for another component, as camel run 
with stubs serves it -->
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-stub</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <!-- the deterministic simulation test that runs chaos rules in 
virtual time (dst package) -->
             <groupId>org.apache.camel</groupId>
diff --git a/components/camel-chaos/src/main/docs/chaos.adoc 
b/components/camel-chaos/src/main/docs/chaos.adoc
index 62a335f5edf5..8d6bd7a724ae 100644
--- a/components/camel-chaos/src/main/docs/chaos.adoc
+++ b/components/camel-chaos/src/main/docs/chaos.adoc
@@ -266,6 +266,28 @@ In JSON the report has `tried`: per `route` its `kinds` 
(`kind`, `rules`, `fired
 (`tried`): `camel cmd chaos-suggest` ends its line with `- tried`, and the 
what to try list of the Camel TUI (key `w`)
 shows both.
 
+=== With stubs
+
+`camel run --stub=remote` serves the remote components with the stub 
component, a queue in memory, so the integration
+runs without the systems behind it, and `camel cmd send` feeds a stubbed 
consumer:
+
+[source,bash]
+----
+camel run * --stub=remote --chaos
+camel cmd send --endpoint=kafka:refunds --body=file:samples/refund.json
+----
+
+The rules match a stub by its URI (`endpoint=kafka:orders`, `route=refunds`), 
and chaos counts a stub as the component
+it stands in for:
+
+* What to try takes a stub as remote when the component is. It reads that from 
the JSON of the component, which
+`camel run` downloads with the component for a stub too.
+* A stubbed consumer keeps a failed message by the options of its URI, as the 
component would:
+`kafka:refunds?breakOnFirstError=true` keeps it. Options set on the component 
do not count, as a stub does not take
+them. The stub does not read the message again, though, so the report says per 
route that its consumer is a stub
+(`stubbedConsumer` in the JSON, `refunds (stub of kafka)` in `camel get chaos 
--report`, and `refunds (stub)` on the
+chaos screen of the Camel TUI): a kept message is then not lost, though 
nothing reads it again while the stub runs.
+
 === From an AI agent (MCP)
 
 The Camel MCP server (`camel mcp`, see xref:manual::camel-jbang-mcp.adoc[Camel 
MCP server]) has the same operations
@@ -762,7 +784,9 @@ per exchange too.
 `acknowledgementModeName=CLIENT_ACKNOWLEDGE` (the endpoint option; the number 
set with `setAcknowledgementMode` in Java
 is not seen) and `asyncConsumer=false`; `kafka` with `breakOnFirstError=true`. 
The options are read through the
 configurer of the endpoint, so options set on the component count too. The 
component is known by the scheme of the
-endpoint URI.
+endpoint URI. A stub that stands in for one of them keeps it by the options of 
its URI (see <<_with_stubs,With stubs>>).
+* `stubbedConsumer`: the component the consumer of the route is a stub of 
(`camel run --stub`), such as `kafka`; left
+out when it is not a stub. A stub does not read a kept message again.
 * `handled`: the completed exchanges after the error handler handled a failure 
(the dead letter channel, or
 `onException` with `handled(true)`). A failure handled in a route called with 
`direct` counts as handled in the
 calling routes too.
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
index 92e99753bb9d..1fa1abd6d935 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosAdvisor.java
@@ -293,7 +293,7 @@ final class ChaosAdvisor {
                         85));
                 continue;
             }
-            if (!to.isRemote()) {
+            if (!ChaosStubs.isRemote(to)) {
                 continue;
             }
             if (LOCAL_FILES.contains(scheme)) {
@@ -340,7 +340,7 @@ final class ChaosAdvisor {
         }
 
         // the input of the route
-        if (from != null && from.isRemote()) {
+        if (from != null && ChaosStubs.isRemote(from)) {
             if (route.getConsumer() instanceof ScheduledPollConsumer) {
                 add(answer, recipes, "poll-error", id, Kind.CONSUMER, 
Map.of("endpoint", uri(from)),
                         "route " + id + " polls " + uri(from)
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosConsumers.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosConsumers.java
index 06503dc654f9..55ab7b42b3fd 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosConsumers.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosConsumers.java
@@ -16,6 +16,8 @@
  */
 package org.apache.camel.component.chaos;
 
+import java.util.function.Function;
+
 import org.apache.camel.Component;
 import org.apache.camel.Endpoint;
 import org.apache.camel.spi.PropertyConfigurer;
@@ -33,7 +35,9 @@ import org.apache.camel.spi.PropertyConfigurerGetter;
  * <li>{@code kafka}: with {@code breakOnFirstError=true}.</li>
  * </ul>
  * The component is known by the scheme of the endpoint, so a component added 
under another name counts as any other.
- * Any other consumer, or an endpoint without a configurer, does not keep it.
+ * Any other consumer, or an endpoint without a configurer, does not keep it. 
A stub that stands in for one of these
+ * components ({@code camel run --stub}) keeps it by the options of its URI, 
as the component would, though the stub
+ * does not read the message again (see {@link ChaosStubs}).
  */
 final class ChaosConsumers {
 
@@ -59,38 +63,45 @@ final class ChaosConsumers {
         if (endpoint == null) {
             return false;
         }
-        PropertyConfigurerGetter options = options(endpoint);
-        if (options == null) {
+        Function<String, Object> option = option(endpoint);
+        if (option == null) {
             return false;
         }
         try {
-            return keepsFailed(endpoint, options);
+            return keepsFailed(endpoint, option);
         } catch (RuntimeException e) {
             // an option that cannot be read: the report says "failed"
             return false;
         }
     }
 
-    private static boolean keepsFailed(Endpoint endpoint, 
PropertyConfigurerGetter options) {
+    private static boolean keepsFailed(Endpoint endpoint, Function<String, 
Object> option) {
         return switch (ChaosPatterns.scheme(endpoint.getEndpointUri())) {
-            case "file", "ftp", "sftp", "ftps" -> 
options.getOptionValue(endpoint, "moveFailed", true) == null;
-            case "jms", "activemq", "activemq6", "amqp" -> 
isTrue(options.getOptionValue(endpoint, "transacted", true))
-                    || isClientAcknowledge(endpoint, options)
-                            && !isTrue(options.getOptionValue(endpoint, 
"asyncConsumer", true));
-            case "kafka" -> isTrue(options.getOptionValue(endpoint, 
"breakOnFirstError", true));
+            case "file", "ftp", "sftp", "ftps" -> option.apply("moveFailed") 
== null;
+            case "jms", "activemq", "activemq6", "amqp" -> 
isTrue(option.apply("transacted"))
+                    || isClientAcknowledge(option) && 
!isTrue(option.apply("asyncConsumer"));
+            case "kafka" -> isTrue(option.apply("breakOnFirstError"));
             default -> false;
         };
     }
 
     // acknowledgementModeName is the endpoint option (acknowledgementMode, 
the number, is not one)
-    private static boolean isClientAcknowledge(Endpoint endpoint, 
PropertyConfigurerGetter options) {
-        return 
"CLIENT_ACKNOWLEDGE".equals(text(options.getOptionValue(endpoint, 
"acknowledgementModeName", true)));
+    private static boolean isClientAcknowledge(Function<String, Object> 
option) {
+        return 
"CLIENT_ACKNOWLEDGE".equals(text(option.apply("acknowledgementModeName")));
     }
 
-    private static PropertyConfigurerGetter options(Endpoint endpoint) {
+    /**
+     * How the options of the endpoint are read: through the configurer of its 
component, or from the URI of a stub, as
+     * the stub component does not configure them; null when they cannot be 
read.
+     */
+    private static Function<String, Object> option(Endpoint endpoint) {
+        if (ChaosStubs.stubbedScheme(endpoint) != null) {
+            return ChaosStubs.uriOptions(endpoint)::get;
+        }
         Component component = endpoint.getComponent();
         PropertyConfigurer configurer = component != null ? 
component.getEndpointPropertyConfigurer() : null;
-        return configurer instanceof PropertyConfigurerGetter getter ? getter 
: null;
+        return configurer instanceof PropertyConfigurerGetter getter
+                ? name -> getter.getOptionValue(endpoint, name, true) : null;
     }
 
     private static boolean isTrue(Object value) {
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
index 2ae52bfad6e9..0e1c01964d7e 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosManager.java
@@ -726,6 +726,10 @@ public final class ChaosManager {
     void routeDone(Route route, Exchange exchange) {
         String routeId = route.getRouteId();
         RouteCounts counts = counts(routeId);
+        if (!counts.consumerChecked) {
+            counts.stubbedConsumer = 
ChaosStubs.stubbedScheme(route.getEndpoint());
+            counts.consumerChecked = true;
+        }
         ChaosExchangeState.Outcome outcome = outcome(route, exchange);
         ChaosExchangeState.RouteState state = 
ChaosExchangeState.of(exchange).route(routeId);
         ChaosExchangeState.Outcome previous = state.counts == counts ? 
state.outcome : ChaosExchangeState.Outcome.ENTERED;
@@ -1089,6 +1093,10 @@ public final class ChaosManager {
         final LongAdder failedKept = new LongAdder();
         final LongAdder handled = new LongAdder();
         final LongAdder redeliveries = new LongAdder();
+        // the component the consumer of the route is a stub of (camel run 
--stub), once a route was done with an
+        // exchange: a stub does not read a kept message again
+        volatile String stubbedConsumer;
+        volatile boolean consumerChecked;
 
         void count(ChaosExchangeState.Outcome outcome) {
             add(outcome, 1);
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
index a2dfd381f5ea..5371d6c484b6 100644
--- 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosReport.java
@@ -267,8 +267,11 @@ public final class ChaosReport {
         }
         for (RouteEntry r : routes) {
             sb.append("\n    ").append(r.route).append(": completed 
").append(r.completed).append(", failed ")
-                    .append(r.failed).append(" (kept by the consumer 
").append(r.failedKept).append("), handled ")
-                    .append(r.handled).append(", redeliveries 
").append(r.redeliveries);
+                    .append(r.failed).append(" (kept by the consumer 
").append(r.failedKept);
+            if (r.stubbedConsumer != null) {
+                sb.append(", a stub of ").append(r.stubbedConsumer).append(" 
that does not read them again");
+            }
+            sb.append("), handled ").append(r.handled).append(", redeliveries 
").append(r.redeliveries);
         }
         if (!tried.isEmpty()) {
             sb.append("\n  tried, per route:");
@@ -456,6 +459,9 @@ public final class ChaosReport {
             jo.put("completed", r.completed);
             jo.put("failed", r.failed);
             jo.put("failedKeptByConsumer", r.failedKept);
+            if (r.stubbedConsumer != null) {
+                jo.put("stubbedConsumer", r.stubbedConsumer);
+            }
             jo.put("handled", r.handled);
             jo.put("redeliveries", r.redeliveries);
             routeArray.add(jo);
@@ -860,6 +866,7 @@ public final class ChaosReport {
         final long failedKept;
         final long handled;
         final long redeliveries;
+        final String stubbedConsumer;
 
         RouteEntry(String route, RouteCounts counts) {
             this.route = route;
@@ -868,6 +875,7 @@ public final class ChaosReport {
             failedKept = counts.failedKept.sum();
             handled = counts.handled.sum();
             redeliveries = counts.redeliveries.sum();
+            stubbedConsumer = counts.stubbedConsumer;
         }
     }
 }
diff --git 
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosStubs.java
 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosStubs.java
new file mode 100644
index 000000000000..fd55cd915cad
--- /dev/null
+++ 
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosStubs.java
@@ -0,0 +1,99 @@
+/*
+ * 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.component.chaos;
+
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.CatalogCamelContext;
+import org.apache.camel.Component;
+import org.apache.camel.Endpoint;
+import org.apache.camel.util.URISupport;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * A stub that stands in for another component: {@code camel run --stub} 
serves a URI such as {@code kafka:refunds} with
+ * the stub component, a queue in memory, so the integration runs without the 
system behind it. Chaos counts such an
+ * endpoint as the component it stands in for: remote when that component is 
remote (from the JSON of the component,
+ * found when its JAR is on the classpath, as {@code camel run} downloads it 
for a stub too), and with the options of
+ * its URI, which the stub component does not configure. The stub component is 
known by its class name, as camel-core
+ * knows it, so camel-chaos does not depend on camel-stub.
+ */
+final class ChaosStubs {
+
+    private static final String STUB_COMPONENT = "StubComponent";
+
+    private ChaosStubs() {
+    }
+
+    /**
+     * The scheme of the component a stub stands in for ({@code kafka} for 
{@code kafka://refunds}), or null when the
+     * endpoint is not a stub, or is a plain {@code stub:} endpoint.
+     */
+    static String stubbedScheme(Endpoint endpoint) {
+        Component component = endpoint != null ? endpoint.getComponent() : 
null;
+        // NOTE: the stub component is not available at compilation time
+        if (component == null || 
!STUB_COMPONENT.equals(component.getClass().getSimpleName())) { // NOSONAR
+            return null;
+        }
+        String scheme = ChaosPatterns.scheme(endpoint.getEndpointUri());
+        return "stub".equals(scheme) ? null : scheme;
+    }
+
+    /** Whether the endpoint is remote: a stub is when the component it stands 
in for is. */
+    static boolean isRemote(Endpoint endpoint) {
+        if (endpoint.isRemote()) {
+            return true;
+        }
+        String scheme = stubbedScheme(endpoint);
+        return scheme != null && isRemoteComponent(endpoint.getCamelContext(), 
scheme);
+    }
+
+    /** Whether the component of the scheme is remote, from its JSON; false 
when it cannot be found. */
+    static boolean isRemoteComponent(CamelContext context, String scheme) {
+        if (!(context instanceof CatalogCamelContext catalog)) {
+            return false;
+        }
+        try {
+            String json = catalog.getComponentParameterJsonSchema(scheme);
+            if (json == null) {
+                return false;
+            }
+            JsonObject component = ((JsonObject) 
Jsoner.deserialize(json)).getMap("component");
+            return component != null && 
component.getBooleanOrDefault("remote", false);
+        } catch (Exception e) {
+            // the JSON of a component that cannot be read: the stub is not 
taken as remote
+            return false;
+        }
+    }
+
+    /** The options in the URI of a stub, by name in any case; empty when the 
URI cannot be read. */
+    static Map<String, Object> uriOptions(Endpoint endpoint) {
+        Map<String, Object> answer = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        String query = URISupport.extractQuery(endpoint.getEndpointUri());
+        if (query != null) {
+            try {
+                answer.putAll(URISupport.parseQuery(query));
+            } catch (Exception e) {
+                // a query that cannot be read has no options
+            }
+        }
+        return answer;
+    }
+}
diff --git 
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosStubsTest.java
 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosStubsTest.java
new file mode 100644
index 000000000000..57d8353dbeb7
--- /dev/null
+++ 
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosStubsTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.component.chaos;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.stub.StubComponent;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Chaos sees through a stub that stands in for another component, as {@code 
camel run --stub=remote} serves
+ * {@code kafka:} with the stub component (CAMEL-26765): the suggestions take 
it as remote when the component is, and a
+ * consumer keeps a failed message by the options of its URI, which the report 
marks as kept by a stub.
+ */
+class ChaosStubsTest extends ChaosTestSupport {
+
+    private static final String REFUNDS = 
"kafka:refunds?brokers=localhost:9092&breakOnFirstError=true";
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        // remotemq is remote by its JSON (in the test resources); kafka has 
no JSON here, as its JAR is not there
+        context.addComponent("remotemq", shadow());
+        context.addComponent("kafka", shadow());
+        return context;
+    }
+
+    private static StubComponent shadow() {
+        StubComponent stub = new StubComponent();
+        stub.setShadow(true);
+        return stub;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("remotemq:in").routeId("mq").to("remotemq:out");
+                from(REFUNDS).routeId("refunds").to("mock:refunds");
+                
from("kafka:payments?brokers=localhost:9092").routeId("payments").to("mock:payments");
+                from("direct:plain").routeId("plain").to("stub:audit");
+            }
+        };
+    }
+
+    private static Map<String, String> rules(List<ChaosAdvisor.Suggestion> 
suggestions) {
+        Map<String, String> answer = new LinkedHashMap<>();
+        suggestions.forEach(s -> answer.put(s.route() + " " + s.recipe() + " " 
+ s.kind().key, s.rule()));
+        return answer;
+    }
+
+    @Test
+    void aStubOfARemoteComponentIsRemote() {
+        
assertThat(ChaosStubs.stubbedScheme(context.getEndpoint("remotemq:in"))).isEqualTo("remotemq");
+        
assertThat(ChaosStubs.isRemote(context.getEndpoint("remotemq:in"))).isTrue();
+        // a component whose JSON is not on the classpath is not taken as 
remote
+        
assertThat(ChaosStubs.isRemote(context.getEndpoint("kafka:payments?brokers=localhost:9092"))).isFalse();
+        // a plain stub: endpoint stands in for nothing
+        
assertThat(ChaosStubs.stubbedScheme(context.getEndpoint("stub:audit"))).isNull();
+        
assertThat(ChaosStubs.isRemote(context.getEndpoint("stub:audit"))).isFalse();
+        
assertThat(ChaosStubs.stubbedScheme(context.getEndpoint("mock:refunds"))).isNull();
+    }
+
+    @Test
+    void theSuggestionsSeeTheStubbedRemoteEndpoints() {
+        Map<String, String> rules = rules(ChaosAdvisor.suggest(context));
+        assertThat(rules)
+                .containsEntry("mq route-error consumer", "route=mq error 
rate=0.05 until=10m")
+                .containsEntry("mq connection-refused errors",
+                        "endpoint=remotemq://out error 
exception=java.net.ConnectException rate=0.25 until=10m");
+        // the stub of audit stands in for nothing, so there is nothing remote 
to try in route plain
+        assertThat(rules.keySet()).noneMatch(k -> k.startsWith("plain "));
+    }
+
+    @Test
+    void aStubOfKafkaKeepsByTheOptionsOfItsUri() throws Exception {
+        
assertThat(ChaosConsumers.keepsFailed(context.getEndpoint(REFUNDS))).isTrue();
+        
assertThat(ChaosConsumers.keepsFailed(context.getEndpoint("kafka:payments?brokers=localhost:9092"))).isFalse();
+
+        chaos().addRule("route=refunds error until=2");
+        chaos().addRule("route=payments error until=1");
+        template.sendBody(REFUNDS, "R-1");
+        template.sendBody(REFUNDS, "R-2");
+        template.sendBody("kafka:payments?brokers=localhost:9092", "P-1");
+        await().atMost(10, TimeUnit.SECONDS)
+                .until(() -> routeCount("refunds", "failed") == 2 && 
routeCount("payments", "failed") == 1);
+
+        // kept by kafka (breakOnFirstError=true), but the stub does not read 
them again: the report says so
+        
assertThat(routeReport("refunds").getLong("failedKeptByConsumer")).isEqualTo(2);
+        
assertThat(routeReport("refunds").getString("stubbedConsumer")).isEqualTo("kafka");
+        
assertThat(routeReport("payments").getLong("failedKeptByConsumer")).isZero();
+        
assertThat(routeReport("payments").getString("stubbedConsumer")).isEqualTo("kafka");
+        assertThat(chaos().report().toText())
+                .contains("refunds: completed 0, failed 2 (kept by the 
consumer 2, a stub of kafka that does not read"
+                          + " them again)");
+
+        // a route whose consumer is not a stub has no mark
+        template.sendBody("direct:plain", "A-1");
+        await().atMost(10, TimeUnit.SECONDS).until(() -> routeCount("plain", 
"completed") == 1);
+        assertThat(routeReport("plain").getString("stubbedConsumer")).isNull();
+    }
+}
diff --git 
a/components/camel-chaos/src/test/resources/META-INF/org/apache/camel/component/chaos/remotemq.json
 
b/components/camel-chaos/src/test/resources/META-INF/org/apache/camel/component/chaos/remotemq.json
new file mode 100644
index 000000000000..d3b7f1890d94
--- /dev/null
+++ 
b/components/camel-chaos/src/test/resources/META-INF/org/apache/camel/component/chaos/remotemq.json
@@ -0,0 +1,10 @@
+{
+  "component": {
+    "kind": "component",
+    "name": "remotemq",
+    "title": "Remote MQ (ChaosStubsTest)",
+    "scheme": "remotemq",
+    "syntax": "remotemq:queue",
+    "remote": true
+  }
+}
diff --git 
a/components/camel-chaos/src/test/resources/META-INF/services/org/apache/camel/component/remotemq
 
b/components/camel-chaos/src/test/resources/META-INF/services/org/apache/camel/component/remotemq
new file mode 100644
index 000000000000..c445612d7ec6
--- /dev/null
+++ 
b/components/camel-chaos/src/test/resources/META-INF/services/org/apache/camel/component/remotemq
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+
+# a remote component that ChaosStubsTest serves with a stub, as camel run 
--stub=remote does: its JSON says it is remote
+class=org.apache.camel.component.chaos.TestPollComponent
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
index bf321715801a..4a95b0894029 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaos.java
@@ -268,7 +268,7 @@ public class ListChaos extends ProcessWatchCommand {
         if (routes != null && !routes.isEmpty()) {
             List<JsonObject> list = 
routes.stream().map(JsonObject.class::cast).toList();
             printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, list, 
List.of(
-                    new 
Column().header("ROUTE").dataAlign(HorizontalAlign.LEFT).with(r -> 
r.getString("route")),
+                    new 
Column().header("ROUTE").dataAlign(HorizontalAlign.LEFT).with(ListChaos::routeName),
                     new 
Column().header("COMPLETED").headerAlign(HorizontalAlign.RIGHT).dataAlign(HorizontalAlign.RIGHT)
                             .with(r -> String.valueOf(r.get("completed"))),
                     new 
Column().header("FAILED").headerAlign(HorizontalAlign.RIGHT).dataAlign(HorizontalAlign.RIGHT)
@@ -280,6 +280,9 @@ public class ListChaos extends ProcessWatchCommand {
                     new 
Column().header("REDELIVERIES").headerAlign(HorizontalAlign.RIGHT)
                             .dataAlign(HorizontalAlign.RIGHT)
                             .with(r -> 
String.valueOf(r.get("redeliveries"))))));
+            if (list.stream().anyMatch(r -> r.getString("stubbedConsumer") != 
null)) {
+                printer().println("  a stub does not read a kept message 
again, as the component it stands in for would");
+            }
         }
         JsonArray recent = report.getCollection("recentFaults");
         if (recent != null && !recent.isEmpty()) {
@@ -418,6 +421,12 @@ public class ListChaos extends ProcessWatchCommand {
         return sj.toString();
     }
 
+    /** The route of the report, and the component its consumer is a stub of 
(camel run --stub). */
+    static String routeName(JsonObject route) {
+        String stub = route.getString("stubbedConsumer");
+        return route.getString("route") + (stub != null ? " (stub of " + stub 
+ ")" : "");
+    }
+
     /** The rule, and below it the rule in plain words (an integration before 
Camel 4.24 has none). */
     static String ruleText(JsonObject rule) {
         String text = rule.getString("rule");
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
index 56be4ec4d512..1bb84ce6b76c 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/process/ListChaosTest.java
@@ -169,6 +169,21 @@ class ListChaosTest extends ProcessCommandTestSupport {
         
assertThat(out).contains("replaced").contains("rate=0.1").contains("orders:100 
unmet (97 completed)");
         assertThat(out).contains("COMPLETED").contains("REDELIVERIES");
         assertThat(lineOf(out, "orders  
")).containsPattern("97\\s+6\\s+2\\s+1\\s+4");
+        assertThat(out).doesNotContain("stub");
+    }
+
+    @Test
+    void aRouteWhoseConsumerIsAStub() throws Exception {
+        JsonObject chaos = chaosOn();
+        JsonObject route = (JsonObject) ((JsonArray) ((JsonObject) 
chaos.get("report")).get("routes")).get(0);
+        route.put("stubbedConsumer", "kafka");
+        writeStatus(chaos);
+
+        assertThat(run("--report")).isZero();
+
+        String out = printer.getStdout();
+        assertThat(lineOf(out, "orders (stub of 
kafka)")).containsPattern("97\\s+6\\s+2\\s+1\\s+4");
+        assertThat(out).contains("a stub does not read a kept message again, 
as the component it stands in for would");
     }
 
     @Test
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
index d9a41f94d876..e9e9e63cef69 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
@@ -249,7 +249,8 @@ final class ChaosInfo {
     private static final List<String> OPTIONS = List.of("at", "in", "when", 
"rate", "until", "from", "expect", "within");
 
     /** The counts of a route in the report. */
-    record RouteCounts(String route, long completed, long failed, long 
failedKept, long handled, long redeliveries) {
+    record RouteCounts(String route, long completed, long failed, long 
failedKept, long handled, long redeliveries,
+            String stubbedConsumer) {
     }
 
     /** The kinds of fault tried in a route, and those the suggestions offer 
that no rule tried yet. */
@@ -333,7 +334,7 @@ final class ChaosInfo {
                         info.routes.add(new RouteCounts(
                                 r.getString("route"), num(r.get("completed"), 
0), num(r.get("failed"), 0),
                                 num(r.get("failedKeptByConsumer"), 0), 
num(r.get("handled"), 0),
-                                num(r.get("redeliveries"), 0)));
+                                num(r.get("redeliveries"), 0), 
r.getString("stubbedConsumer")));
                     }
                 }
             }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
index 2cc5cef96a17..ad9c093807e6 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
@@ -646,8 +646,12 @@ class ChaosTab extends AbstractTableTab {
     private static void renderRoutes(Frame frame, Rect area, ChaosInfo chaos) {
         List<Row> rows = new ArrayList<>();
         for (ChaosInfo.RouteCounts c : chaos.routes) {
+            // a consumer that is a stub (camel run --stub) does not read a 
kept message again; the panel is narrow, so
+            // the component it stands in for is left to the report
+            Span name = Span.styled(c.route() != null ? c.route() : "", 
Style.EMPTY.fg(Theme.accent()));
+            Line route = c.stubbedConsumer() != null ? Line.from(name, 
Span.styled(" (stub)", Theme.muted())) : Line.from(name);
             rows.add(Row.from(
-                    Cell.from(Span.styled(c.route() != null ? c.route() : "", 
Style.EMPTY.fg(Theme.accent()))),
+                    Cell.from(route),
                     rightCell(String.valueOf(c.completed()), 6),
                     rightCell(String.valueOf(c.failed()), 5, c.failed() > 0 ? 
Theme.error() : Style.EMPTY),
                     rightCell(String.valueOf(c.failedKept()), 5),
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
index be424cf4fe3f..cfa741608e6d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
@@ -73,7 +73,7 @@ class ChaosTabRenderTest {
                   {"route": "orders", "completed": 97, "failed": 0, 
"failedKeptByConsumer": 0, "handled": 2,
                    "redeliveries": 44},
                   {"route": "stock", "completed": 141, "failed": 0, 
"failedKeptByConsumer": 0, "handled": 0,
-                   "redeliveries": 0}
+                   "redeliveries": 0, "stubbedConsumer": "kafka"}
                 ],
                 "recentFaults": [
                   {"timestamp": 1000, "ago": 2000, "rule": "r1", "version": 1, 
"fault": "error status 503",
@@ -132,6 +132,8 @@ class ChaosTabRenderTest {
         assertThat(screen).contains("round 1 of 3").contains("route 
poller").contains("down, back in 8s");
         // the routes
         
assertThat(screen).contains("ROUTE").contains("orders").contains("141");
+        // a route whose consumer is a stub (camel run --stub) says so
+        assertThat(screen).contains("stock (stub)");
     }
 
     @Test

Reply via email to