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 fdbc617ea42a CAMEL-26699: camel-jbang - chaos screen: poll targets,
pick order, expectation count, effect of delays and polls (#29358)
fdbc617ea42a is described below
commit fdbc617ea42ac7d18cf425b2fb7fe700ed5f5813
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 22:08:24 2026 +0100
CAMEL-26699: camel-jbang - chaos screen: poll targets, pick order,
expectation count, effect of delays and polls (#29358)
Signed-off-by: Claus Ibsen <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/camel/component/chaos/ChaosRule.java | 7 +-
.../camel/component/chaos/ChaosRuleTest.java | 23 +++++
.../dsl/jbang/core/commands/tui/ChaosHere.java | 6 +-
.../dsl/jbang/core/commands/tui/ChaosInfo.java | 63 +++++++++++-
.../dsl/jbang/core/commands/tui/ChaosRecipes.java | 47 ++++++---
.../dsl/jbang/core/commands/tui/ChaosTab.java | 110 +++++++++++++++++++--
.../dsl/jbang/core/commands/tui/Confirmations.java | 38 +++++--
.../dsl/jbang/core/commands/tui/ConsumerInfo.java | 2 +
.../dsl/jbang/core/commands/tui/McpFacade.java | 6 ++
.../dsl/jbang/core/commands/tui/MonitorTab.java | 9 ++
.../dsl/jbang/core/commands/tui/StatusParser.java | 2 +
.../jbang/core/commands/tui/TuiToolRegistry.java | 11 ++-
.../src/main/resources/tui/help/chaos.md | 9 +-
.../jbang/core/commands/tui/ChaosRecipesTest.java | 46 +++++++++
.../core/commands/tui/ChaosTabRenderTest.java | 61 ++++++++++++
.../jbang/core/commands/tui/ConfirmationsTest.java | 39 ++++++++
16 files changed, 438 insertions(+), 41 deletions(-)
diff --git
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
index b758e277bc82..80f77a1d9680 100644
---
a/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
+++
b/components/camel-chaos/src/main/java/org/apache/camel/component/chaos/ChaosRule.java
@@ -651,7 +651,8 @@ final class ChaosRule {
/**
* The route of this expectation counted one more of its kind. The
time is read under the lock, so once a report
- * saw the expectation decided, a count that comes later cannot change
it.
+ * saw the expectation decided, a count that comes later cannot change
it. The count goes on after that, until
+ * the time is up, so the report says how far it went over: "6
exchanges handled, at most 0 expected".
*/
synchronized void record() {
recordAt(TimeSource.currentTimeMillis());
@@ -659,11 +660,11 @@ final class ChaosRule {
/** The route of this expectation counted one more of its kind at the
given time (the tests give the time). */
synchronized void recordAt(long now) {
- if (decided != null || frozenAt >= 0 || now > deadline) {
+ if (frozenAt >= 0 || now > deadline) {
return;
}
value++;
- if (op == Op.AT_LEAST ? value >= count : value > count) {
+ if (decided == null && (op == Op.AT_LEAST ? value >= count : value
> count)) {
decided = op == Op.AT_LEAST ? Outcome.MET : Outcome.UNMET;
decidedAt = now;
}
diff --git
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
index 223b6035579e..c0499fcdbb0c 100644
---
a/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
+++
b/components/camel-chaos/src/test/java/org/apache/camel/component/chaos/ChaosRuleTest.java
@@ -176,6 +176,29 @@ class ChaosRuleTest {
assertThat(removedLate.outcome(T0 + 1000)).isEqualTo(Outcome.UNMET);
}
+ @Test
+ void aDecidedExpectationCountsOnUntilTheTimeIsUp() {
+ // CAMEL-26699: unmet at the first exchange handled, and the count
says how far it went over
+ ChaosRule.Expectation atMost = new ChaosRule.Expectation(
+ "orders", ChaosRule.Expectation.Kind.HANDLED,
ChaosRule.Expectation.Op.AT_MOST, 0, T0 + 100);
+ atMost.recordAt(T0 + 10);
+ assertThat(atMost.outcome(T0 + 10)).isEqualTo(Outcome.UNMET);
+ atMost.recordAt(T0 + 20);
+ atMost.recordAt(T0 + 30);
+ assertThat(atMost.value()).isEqualTo(3);
+ assertThat(atMost.outcome(T0 + 1000)).isEqualTo(Outcome.UNMET);
+ // not after the time is up
+ atMost.recordAt(T0 + 101);
+ assertThat(atMost.value()).isEqualTo(3);
+
+ // met at the count, and the count goes on: the outcome stays met
+ ChaosRule.Expectation atLeast = new ChaosRule.Expectation("orders", 1,
T0 + 100);
+ atLeast.recordAt(T0 + 10);
+ atLeast.recordAt(T0 + 20);
+ assertThat(atLeast.value()).isEqualTo(2);
+ assertThat(atLeast.outcome(T0 + 1000)).isEqualTo(Outcome.MET);
+ }
+
@Test
void withinDefaultsToTheUntilDurationElseMaxDuration() {
assertThat(rule("endpoint=x error until=1m expect=o:1", "r1",
1).within).isEqualTo(60_000L);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
index 53872d6f11c2..d55a3ca2b470 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
@@ -103,7 +103,7 @@ final class ChaosHere {
String uri = consumer.uri != null ? withoutQuery(consumer.uri) : null;
return new Target(
"route " + consumer.id + (uri != null ? " (" + uri + ")" :
""), null,
- consumer.scheduled ? uri : null, consumer.id, null);
+ consumer.polls ? uri : null, consumer.id, null);
}
/**
@@ -117,7 +117,7 @@ final class ChaosHere {
}
String polled = null;
for (ConsumerInfo c : info.consumers) {
- if (routeId.equals(c.id) && c.scheduled && c.uri != null) {
+ if (routeId.equals(c.id) && c.polls && c.uri != null) {
polled = withoutQuery(c.uri);
}
}
@@ -137,7 +137,7 @@ final class ChaosHere {
private static String polled(IntegrationInfo info, String uri) {
for (ConsumerInfo c : info.consumers) {
- if (c.scheduled && uri.equals(c.uri)) {
+ if (c.polls && uri.equals(c.uri)) {
return withoutQuery(uri);
}
}
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 d0a63c9ca646..668d5517f146 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
@@ -52,6 +52,8 @@ final class ChaosInfo {
String state;
long matches;
long fired;
+ // the faults it fired per hook, such as poll=39 (CAMEL-26699)
+ final Map<String, Long> firedAt = new LinkedHashMap<>();
long skipped;
final Map<String, Long> skippedReasons = new LinkedHashMap<>();
long lostAtDlq = -1;
@@ -121,6 +123,62 @@ final class ChaosInfo {
return sb.toString();
}
+ /**
+ * The fault in a few words, as the report says it: {@code error
status 503}, {@code error ConnectException},
+ * {@code delay 500ms..1s}, {@code stop 5s ×3 every 20s}.
+ */
+ String faultShort() {
+ List<String> tokens = new ArrayList<>(List.of(fault().split(" ")));
+ if (tokens.isEmpty() || tokens.get(0).isEmpty()) {
+ return "";
+ }
+ String kind = tokens.remove(0);
+ Map<String, String> params = new LinkedHashMap<>();
+ for (String token : tokens) {
+ int eq = token.indexOf('=');
+ params.put(eq > 0 ? token.substring(0, eq) : token, eq > 0 ?
token.substring(eq + 1) : "");
+ }
+ StringBuilder sb = new StringBuilder();
+ switch (kind) {
+ case "error" -> {
+ sb.append("error");
+ String exception = params.get("exception");
+ if (params.containsKey("status")) {
+ sb.append(" status ").append(params.get("status"));
+ } else {
+ // the exception camel-chaos throws when the rule
names none
+ String type = exception != null ? exception :
"ChaosException";
+ sb.append('
').append(type.substring(type.lastIndexOf('.') + 1));
+ }
+ if (params.containsKey("after")) {
+ sb.append(" after ").append(params.get("after"));
+ }
+ }
+ case "delay", "stall" -> sb.append(kind).append('
').append(params.getOrDefault("duration", ""));
+ case "corrupt" -> sb.append("corrupt
").append(params.getOrDefault("mode", ""))
+ .append(params.containsKey("header") ? " " +
params.get("header") : "");
+ case "lifecycle" -> {
+ sb.append(params.getOrDefault("action",
"lifecycle")).append(' ')
+ .append(params.getOrDefault("duration", ""));
+ if (params.containsKey("repeat")) {
+ sb.append(" ×").append(params.get("repeat"));
+ }
+ if (params.containsKey("every")) {
+ sb.append(" every ").append(params.get("every"));
+ }
+ }
+ default -> {
+ return fault();
+ }
+ }
+ return sb.toString().strip();
+ }
+
+ /** The faults it fired at the polls of a consumer: a poll rule faults
no exchange. */
+ long firedAtPoll() {
+ return firedAt.getOrDefault("poll", 0L);
+ }
+
/** The options at, rate, until and from as they are written, for the
RATE column. */
String option(String name) {
for (String token : tokens()) {
@@ -260,9 +318,10 @@ final class ChaosInfo {
r.state = jo.getString("state");
r.matches = num(jo.get("matches"), 0);
if (jo.get("fired") instanceof Map<?, ?> fired) {
- for (Object v : fired.values()) {
+ fired.forEach((k, v) -> {
r.fired += num(v, 0);
- }
+ r.firedAt.put(String.valueOf(k), num(v, 0));
+ });
}
r.skipped = num(jo.get("skipped"), 0);
if (jo.get("skippedReasons") instanceof Map<?, ?> reasons) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java
index dbf220fe0a71..71200da83209 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipes.java
@@ -137,19 +137,10 @@ final class ChaosRecipes {
}
switch (p.pickFrom()) {
case "values" -> answer.addAll(p.values());
- case "producers" -> {
- for (ProducerInfo producer : info.producers) {
- addUri(answer, producer.uri, p.schemes());
- }
- for (EndpointInfo endpoint : info.endpoints) {
- if ("out".equalsIgnoreCase(endpoint.direction)) {
- addUri(answer, endpoint.uri, p.schemes());
- }
- }
- }
+ case "producers" -> answer.addAll(producers(info, p.schemes()));
case "pollingConsumers" -> {
for (ConsumerInfo consumer : info.consumers) {
- if (consumer.scheduled) {
+ if (consumer.polls) {
addUri(answer, consumer.uri, p.schemes());
}
}
@@ -179,6 +170,40 @@ final class ChaosRecipes {
return List.copyOf(answer);
}
+ /**
+ * The endpoints the routes send to: the calls to other systems first
(remote), then the sends within the
+ * integration, and last the endpoints only an error handler sends to,
such as a dead letter channel. That one has a
+ * producer in every route, but no route sends to it (the endpoints have
no {@code out} entry for it), and it has a
+ * recipe of its own. An {@code out} endpoint that a route consumes, such
as the reply of {@code platform-http}, is
+ * not a send.
+ */
+ private static List<String> producers(IntegrationInfo info, List<String>
schemes) {
+ Set<String> sentTo = new LinkedHashSet<>();
+ Set<String> consumed = new LinkedHashSet<>();
+ for (EndpointInfo endpoint : info.endpoints) {
+ addUri("out".equalsIgnoreCase(endpoint.direction) ? sentTo :
consumed, endpoint.uri, List.of());
+ }
+ Set<String> remote = new LinkedHashSet<>();
+ Set<String> local = new LinkedHashSet<>();
+ Set<String> errorHandler = new LinkedHashSet<>();
+ for (ProducerInfo producer : info.producers) {
+ Set<String> uri = new LinkedHashSet<>();
+ addUri(uri, producer.uri, schemes);
+ uri.forEach(u -> (producer.remote ? remote : sentTo.contains(u) ?
local : errorHandler).add(u));
+ }
+ for (EndpointInfo endpoint : info.endpoints) {
+ if ("out".equalsIgnoreCase(endpoint.direction)) {
+ Set<String> uri = new LinkedHashSet<>();
+ addUri(uri, endpoint.uri, schemes);
+ uri.stream().filter(u ->
!consumed.contains(u)).forEach((endpoint.remote ? remote : local)::add);
+ }
+ }
+ List<String> answer = new ArrayList<>(remote);
+ local.stream().filter(u -> !answer.contains(u)).forEach(answer::add);
+ errorHandler.stream().filter(u ->
!answer.contains(u)).forEach(answer::add);
+ return answer;
+ }
+
// an id Camel made up, such as to1 or log3
private static boolean isGeneratedId(String id) {
int i = id.length();
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 2127e8154b48..cadfadf6db76 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
@@ -17,15 +17,19 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import dev.tamboui.layout.Constraint;
import dev.tamboui.layout.Layout;
import dev.tamboui.layout.Rect;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.CharWidth;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.text.Text;
@@ -56,6 +60,7 @@ class ChaosTab extends AbstractTableTab {
// the width of the sparkline in the rules table: the 60 seconds, 4 per
column
private static final int TABLE_SPARK = 15;
private static final int ROUTES_WIDTH = 46;
+ private static final int MAX_EFFECT_WIDTH = 30;
private final ChaosControl control;
@@ -98,6 +103,12 @@ class ChaosTab extends AbstractTableTab {
return control.isConfirming();
}
+ @Override
+ public boolean isConfirmCancelledByEsc() {
+ // Esc closes the confirm of a chaos action without doing it
+ return true;
+ }
+
@Override
public boolean handlePaste(String text) {
return control.handlePaste(text);
@@ -150,7 +161,7 @@ class ChaosTab extends AbstractTableTab {
List<Rect> bottom = Layout.horizontal()
.constraints(Constraint.fill(),
Constraint.length(ROUTES_WIDTH))
.split(rows.get(2));
- renderDetail(frame, bottom.get(0), chaos, selectedRule(chaos));
+ renderDetail(frame, bottom.get(0), info, selectedRule(chaos));
renderRoutes(frame, bottom.get(1), chaos);
control.render(frame, area);
}
@@ -227,7 +238,7 @@ class ChaosTab extends AbstractTableTab {
Cell.from(Span.styled(r.id + (r.version > 1 ? " v" +
r.version : ""), Style.EMPTY.fg(Theme.accent()))),
Cell.from(Span.styled(r.state != null ? r.state : "",
stateStyle(r.state))),
Cell.from(target(r)),
- Cell.from(r.fault()),
+ Cell.from(r.faultShort()),
Cell.from(rate(r)),
rightCell(String.valueOf(r.fired), 5),
rightCell(r.lastFiredAgo >= 0 ? shortTime(r.lastFiredAgo)
: "-", 6),
@@ -246,6 +257,13 @@ class ChaosTab extends AbstractTableTab {
return;
}
Style bold = Style.EMPTY.bold();
+ // EFFECT is as wide as its counts need ("62↩ 1…" was cut), TARGET and
FAULT share the rest
+ int effectWidth = CharWidth.of("EFFECT");
+ for (ChaosInfo.Rule r : chaos.rules) {
+ effectWidth = Math.max(effectWidth,
CharWidth.of(Line.from(effect(r)).rawContent()));
+ }
+ // one more, so the counts do not run into the note
+ effectWidth = Math.min(effectWidth + 1, MAX_EFFECT_WIDTH);
Table table = Table.builder()
.rows(rows)
.header(Row.from(
@@ -268,7 +286,7 @@ class ChaosTab extends AbstractTableTab {
Constraint.length(5),
Constraint.length(6),
Constraint.length(TABLE_SPARK),
- Constraint.length(12),
+ Constraint.length(effectWidth),
Constraint.fill(4))
.highlightStyle(Theme.selectionBg())
.highlightSpacing(Table.HighlightSpacing.ALWAYS)
@@ -317,16 +335,27 @@ class ChaosTab extends AbstractTableTab {
static List<Span> effect(ChaosInfo.Rule r) {
List<Span> spans = new ArrayList<>();
if (r.faulted <= 0) {
+ // a poll rule faults no exchange: its effect is on the polls
(CAMEL-26699)
+ if (r.firedAtPoll() > 0) {
+ spans.add(Span.styled(r.firedAtPoll() + (r.firedAtPoll() == 1
? " poll " : " polls ") + pollWord(r),
+ r.faultShort().startsWith("error") ? Theme.error() :
Theme.warning()));
+ }
return spans;
}
addCount(spans, r.completed, "✓", Theme.success());
addCount(spans, r.recovered, "↩", Theme.warning());
addCount(spans, r.handled, "⇩", Theme.error());
addCount(spans, r.failed, TuiIcons.CROSS, Theme.error().bold());
- addCount(spans, r.inFlight, "…", Theme.muted());
+ // not … , which reads as a cut text
+ addCount(spans, r.inFlight, "◷", Theme.muted());
return spans;
}
+ /** What a poll rule did to the polls: an error fails them, a drop skips
them. */
+ private static String pollWord(ChaosInfo.Rule r) {
+ return r.faultShort().startsWith("error") ? "failed" : "skipped";
+ }
+
private static void addCount(List<Span> spans, long count, String mark,
Style style) {
if (count > 0) {
if (!spans.isEmpty()) {
@@ -381,7 +410,8 @@ class ChaosTab extends AbstractTableTab {
// ---- the selected rule ----
- private static void renderDetail(Frame frame, Rect area, ChaosInfo chaos,
ChaosInfo.Rule r) {
+ private static void renderDetail(Frame frame, Rect area, IntegrationInfo
info, ChaosInfo.Rule r) {
+ ChaosInfo chaos = info.chaos;
if (r == null) {
frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(Span.raw(""))))
.block(block(" Rule ")).build(), area);
@@ -410,7 +440,13 @@ class ChaosTab extends AbstractTableTab {
if (r.faulted > 0) {
lines.add(effectBar(r, Math.max(10, Math.min(40, area.width() -
40))));
lines.add(Line.from(effectWords(r)));
+ } else if (r.firedAtPoll() > 0) {
+ lines.add(Line.from(Span.styled(" Effect ", label), Span.styled(
+ r.firedAtPoll() + (r.firedAtPoll() == 1 ? " poll " : "
polls ") + pollWord(r)
+
+ ": the consumer polls again on its schedule",
+ Theme.warning())));
}
+ addImpact(lines, info, chaos, r);
if (r.expectRoute != null) {
String spec = "completed".equals(r.expectKind) &&
">=".equals(r.expectOp)
? r.expectRoute + ":" + r.expectCount
@@ -436,16 +472,15 @@ class ChaosTab extends AbstractTableTab {
Span.styled(skip.getValue() + ": " + skip.getKey(),
Theme.warning())));
}
boolean first = true;
- for (ChaosInfo.RecentFault f : chaos.recentFaults) {
- if (r.id == null || !r.id.equals(f.rule())) {
- continue;
- }
+ for (Map.Entry<ChaosInfo.RecentFault, Integer> e : recentOf(chaos,
r).entrySet()) {
+ ChaosInfo.RecentFault f = e.getKey();
lines.add(Line.from(
Span.styled(first ? " Recent " : " ", label),
Span.styled(String.format("%-6s", f.ago() >= 0 ?
shortTime(f.ago()) : ""), label),
Span.styled("🎲 ", Theme.warning()),
Span.raw(f.fault() != null ? f.fault() : ""),
- Span.styled(where(f), label)));
+ Span.styled(where(f), label),
+ Span.styled(e.getValue() > 1 ? " ×" + e.getValue() : "",
Theme.warning())));
first = false;
}
frame.renderWidget(Paragraph.builder()
@@ -454,6 +489,61 @@ class ChaosTab extends AbstractTableTab {
.build(), area);
}
+ /**
+ * The recent faults of a rule, the latest first, with how many times each
exchange was faulted: a redelivery is
+ * faulted again, and is shown once with its count ({@code ×3}), not once
per attempt.
+ */
+ static Map<ChaosInfo.RecentFault, Integer> recentOf(ChaosInfo chaos,
ChaosInfo.Rule r) {
+ Map<ChaosInfo.RecentFault, Integer> answer = new LinkedHashMap<>();
+ Map<String, ChaosInfo.RecentFault> byExchange = new LinkedHashMap<>();
+ for (ChaosInfo.RecentFault f : chaos.recentFaults) {
+ if (r.id == null || !r.id.equals(f.rule())) {
+ continue;
+ }
+ ChaosInfo.RecentFault seen = f.exchangeId() != null ?
byExchange.get(f.exchangeId()) : null;
+ if (seen != null) {
+ answer.merge(seen, 1, Integer::sum);
+ } else {
+ if (f.exchangeId() != null) {
+ byExchange.put(f.exchangeId(), f);
+ }
+ answer.put(f, 1);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The routes the faults of the rule were in, with their times and
throughput: what a delay costs, which the effect
+ * does not say (the exchanges complete), is in the times of the route.
+ */
+ private static void addImpact(List<Line> lines, IntegrationInfo info,
ChaosInfo chaos, ChaosInfo.Rule r) {
+ Set<String> routes = new LinkedHashSet<>();
+ for (ChaosInfo.RecentFault f : chaos.recentFaults) {
+ if (r.id != null && r.id.equals(f.rule()) && f.route() != null) {
+ routes.add(f.route());
+ }
+ }
+ boolean first = true;
+ for (String routeId : routes) {
+ RouteInfo route = info.routes.stream().filter(ri ->
routeId.equals(ri.routeId)).findFirst().orElse(null);
+ if (route == null || route.total <= 0) {
+ continue;
+ }
+ StringBuilder sb = new StringBuilder(routeId).append(" mean
").append(route.meanTime).append("ms");
+ if (route.p99Time >= 0) {
+ sb.append(" · p95 ").append(route.p95Time).append("ms · p99
").append(route.p99Time).append("ms");
+ } else {
+ sb.append(" · max ").append(route.maxTime).append("ms");
+ }
+ if (route.throughput != null && !route.throughput.isEmpty()) {
+ sb.append(" · ").append(route.throughput).append(" msg/s");
+ }
+ lines.add(Line.from(Span.styled(first ? " Routes " : "
", Theme.muted()), Span.raw(sb.toString())));
+ first = false;
+ }
+ }
+
private static String where(ChaosInfo.RecentFault f) {
StringBuilder sb = new StringBuilder();
if (f.hook() != null) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
index bb2ae71bd732..9076cd41f9bb 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Confirmations.java
@@ -22,6 +22,7 @@ import java.util.Queue;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
+import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import org.apache.camel.dsl.jbang.core.commands.ai.ToolConfirms;
@@ -30,12 +31,14 @@ import
org.apache.camel.dsl.jbang.core.commands.ai.ToolConfirms;
* of an AI tool ({@link ToolConfirms}), an AI edit waiting to be saved, a
tool of its own an ACP agent wants to run,
* deleting a file or discarding changes. Only the user answers them
(CAMEL-25444): a key an MCP client sends
* (tui_send_keys) or a tape plays never reaches one, and is dropped while one
is open. While a tape drives the monitor
- * nothing asks at all ({@link MonitorContext#confirmFirst}), as the model or
the tape does everything itself.
+ * nothing asks at all ({@link MonitorContext#confirmFirst}), as the model or
the tape does everything itself. A lone
+ * Esc still reaches a dialog that Esc only cancels ({@link
MonitorTab#isConfirmCancelledByEsc()}), so an MCP client can
+ * close a dialog it opened (CAMEL-26699).
*/
final class Confirmations {
- /** A dialog that asks to confirm, and whether it is open now. */
- private record Dialog(String name, BooleanSupplier open) {
+ /** A dialog that asks to confirm, whether it is open now, and whether Esc
only cancels it. */
+ private record Dialog(String name, BooleanSupplier open, BooleanSupplier
cancelledByEsc) {
}
private final List<Dialog> dialogs = new ArrayList<>();
@@ -61,15 +64,33 @@ final class Confirmations {
.add("a confirm dialog of the tab", () -> {
MonitorTab tab = tabs.activeTab();
return tab != null && tab.isConfirming();
+ }, () -> {
+ MonitorTab tab = tabs.activeTab();
+ return tab != null && tab.isConfirmCancelledByEsc();
});
}
/** Adds a dialog that asks to confirm, named as the user sees it. */
Confirmations add(String name, BooleanSupplier open) {
- dialogs.add(new Dialog(name, open));
+ return add(name, open, () -> false);
+ }
+
+ /** Adds a dialog that asks to confirm, and whether Esc only cancels it. */
+ Confirmations add(String name, BooleanSupplier open, BooleanSupplier
cancelledByEsc) {
+ dialogs.add(new Dialog(name, open, cancelledByEsc));
return this;
}
+ /** Whether the dialog that is open is one that Esc only cancels (false
when none is open). */
+ boolean isOpenCancelledByEsc() {
+ for (Dialog dialog : dialogs) {
+ if (dialog.open().getAsBoolean()) {
+ return dialog.cancelledByEsc().getAsBoolean();
+ }
+ }
+ return false;
+ }
+
/** The dialog that asks to confirm and is open, or null when none is. */
String open() {
for (Dialog dialog : dialogs) {
@@ -82,7 +103,8 @@ final class Confirmations {
/**
* Delivers the keys an MCP client or a tape sent that are due now, one by
one, as a key can open a dialog. A key
- * that comes while a dialog asks to confirm is dropped: only the user
answers it.
+ * that comes while a dialog asks to confirm is dropped: only the user
answers it. An Esc that only cancels the
+ * dialog gets through.
*
* @return whether a key was taken from the queue
*/
@@ -92,7 +114,7 @@ final class Confirmations {
while ((key = pendingKeys.peek()) != null && key.isDue(now)) {
pendingKeys.poll();
taken = true;
- if (open() != null) {
+ if (open() != null && !(isEsc(key.event()) &&
isOpenCancelledByEsc())) {
continue;
}
delivering = true;
@@ -105,6 +127,10 @@ final class Confirmations {
return taken;
}
+ private static boolean isEsc(KeyEvent ke) {
+ return ke.isKey(KeyCode.ESCAPE) && !ke.hasCtrl() && !ke.hasAlt() &&
!ke.hasShift();
+ }
+
/** Whether the key being handled was sent by an MCP client or a tape, not
typed by the user. */
boolean isDelivering() {
return delivering;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumerInfo.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumerInfo.java
index 62fb00441da1..403c83db4ed8 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumerInfo.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumerInfo.java
@@ -22,6 +22,8 @@ class ConsumerInfo {
String state;
String className;
boolean scheduled;
+ // a scheduled poll consumer (file, ftp, sql, ...): it has a scheduler,
which a timer has not (CAMEL-26699)
+ boolean polls;
boolean remote;
int inflight;
Boolean polling;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index 6071aff381b6..34c173cd5b7a 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -298,6 +298,12 @@ class McpFacade {
return c != null ? c.open() : null;
}
+ /** Whether the dialog that asks to confirm and is open is one that Esc
only cancels (CAMEL-26699). */
+ boolean isOpenConfirmationCancelledByEsc() {
+ Confirmations c = confirmations;
+ return c != null && c.isOpenCancelledByEsc();
+ }
+
void setFullScan(Runnable request, LongSupplier done) {
this.fullScanRequest = request;
this.fullScansDone = done;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
index 0cd774be2449..7f597ea54795 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
@@ -71,6 +71,15 @@ interface MonitorTab {
return false;
}
+ /**
+ * Whether Esc only cancels the confirm dialog of this tab ({@link
#isConfirming()}), so an MCP client may send it
+ * to close a dialog it opened: it can never answer yes. False by default,
as Esc means another answer in some
+ * dialogs (CAMEL-26699).
+ */
+ default boolean isConfirmCancelledByEsc() {
+ return false;
+ }
+
/**
* Pastes text into the text field that has the keyboard; returns {@code
false} when none has, as the default does.
*/
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
index 35e6a38654e7..c3ccaaa7917d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
@@ -426,6 +426,8 @@ final class StatusParser {
// the consumers console says clazz (class in older
versions)
ci.className = cj.getStringOrDefault("clazz",
cj.getString("class"));
ci.scheduled = Boolean.TRUE.equals(cj.get("scheduled"));
+ // only a scheduled poll consumer has a scheduler: at=poll
of camel-chaos hooks its polls
+ ci.polls = cj.get("schedulerClass") != null;
ci.remote = Boolean.TRUE.equals(cj.get("remote"));
ci.inflight = cj.getIntegerOrDefault("inflight", 0);
ci.polling = Boolean.TRUE.equals(cj.get("polling"));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 6dfe562d3e00..63d187fea689 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -505,10 +505,15 @@ class TuiToolRegistry {
return "Error: keys array is empty";
}
String confirmation = facade.openConfirmation();
- if (confirmation != null) {
- // only the user answers a dialog that asks to confirm an action
(CAMEL-25444)
+ boolean escCancels = keys.size() == 1
+ && ("Esc".equalsIgnoreCase(keys.get(0)) ||
"Escape".equalsIgnoreCase(keys.get(0)))
+ && facade.isOpenConfirmationCancelledByEsc();
+ if (confirmation != null && !escCancels) {
+ // only the user answers a dialog that asks to confirm an action
(CAMEL-25444); a lone Esc may cancel a
+ // dialog that Esc only cancels, never answer it (CAMEL-26699)
return "Not sent: " + confirmation + " is open, and only the user
answers it. Wait for the user, or tell"
- + " them what you need: keys sent while it is open are
dropped.";
+ + " them what you need: keys sent while it is open are
dropped"
+ + (facade.isOpenConfirmationCancelledByEsc() ? " (a lone
Esc cancels it)." : ".");
}
int delay = 150;
Object delayArg = args.get("delay");
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
index be5a638d025f..b812000b0429 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
@@ -31,12 +31,12 @@ minute, and the seed of the random draws.
- **ID** — The id of the rule (r1, r2, ...), with its version when it was
changed
- **STATE** — `active`, `waiting` (it starts later, `from=`) or `ended`
- **TARGET** — What the rule matches: `endpoint=`, `component=`, `route=` or
`step=`
-- **FAULT** — What it does, with its parameters, such as `error status=503`
+- **FAULT** — What it does, in a few words: `error status 503`, `error
ConnectException`, `delay 500ms..1s`, `stop 5s ×3 every 20s` (the rule in full
is the title of the panel under the table)
- **RATE** — The share of the matches it fired at, next to its own: `24% of
25%`, `20% of 1 in 5`, `16% of 5s in 30s`; for a flapping route the round
- **FIRED** — How many faults it fired
- **LAST** — How long ago it last fired
- **FAULTS/S** — Its faults per second over the last minute, as a sparkline
-- **EFFECT** — What its faults did to the exchanges: `✓` completed, `↩`
recovered by redelivery, `⇩` handled by the error handler (such as the dead
letter channel), `✗` failed, `…` in flight
+- **EFFECT** — What its faults did to the exchanges: `✓` completed, `↩`
recovered by redelivery, `⇩` handled by the error handler (such as the dead
letter channel), `✗` failed, `◷` in flight. A rule at the polls of a consumer
faults no exchange: its effect is the polls it failed or skipped (`39 polls
failed`)
- **NOTE** — When it ends or starts, its bursts, what a stall or lifecycle
rule does to its route, and the faults it skipped
## The selected rule
@@ -45,7 +45,10 @@ The panel under the table shows the selected rule in full:
its faults
per second, rate, the effect as a bar with the counts in words, its
expectation with the time left, why it skipped faults, and its recent
faults: when, the fault, where (the endpoint or step), the route and the
-exchange.
+exchange. An exchange faulted again on its redeliveries is shown once, with
+how many times (`×3`). The routes of its recent faults are listed with
+their mean time, p95 and p99, and throughput: what a delay costs, as the
+exchanges it delayed still complete.
## Routes
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java
index 6a141232fa93..c27656c89d3f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipesTest.java
@@ -77,11 +77,14 @@ class ChaosRecipesTest {
producer.uri = uri;
info.producers.add(producer);
}
+ // both are scheduled, as the consumers console says; only the file
consumer polls (it has a scheduler)
ConsumerInfo timer = new ConsumerInfo();
timer.uri = "timer://orders?period=500";
+ timer.scheduled = true;
ConsumerInfo files = new ConsumerInfo();
files.uri = "file://inbox?delay=1000";
files.scheduled = true;
+ files.polls = true;
info.consumers.add(timer);
info.consumers.add(files);
for (String id : List.of("orders", "inventory")) {
@@ -137,6 +140,49 @@ class ChaosRecipesTest {
assertThat(ChaosRecipes.candidates(r.get(3).placeholders().get(1),
info)).isEmpty();
}
+ @Test
+ void theCallsToOtherSystemsFirstAndTheDeadLetterChannelLast() {
+ // CAMEL-26699: as the status of the demo shop has them
+ IntegrationInfo info = new IntegrationInfo();
+ for (String[] p : new String[][] {
+ { "direct://payment", "false" }, { "seda://dlq", "false" }, {
"seda://shipping", "false" },
+ { "direct://inventory", "false" }, {
"http://localhost:8765/stock", "true" },
+ { "seda://dlq", "false" }, { "http://localhost:8765/pay",
"true" } }) {
+ ProducerInfo producer = new ProducerInfo();
+ producer.uri = p[0];
+ producer.remote = Boolean.parseBoolean(p[1]);
+ info.producers.add(producer);
+ }
+ for (String[] e : new String[][] {
+ { "direct://inventory", "in", "false" }, {
"direct://inventory", "out", "false" },
+ { "platform-http:///stock", "in", "true" }, {
"platform-http:///stock", "out", "true" },
+ { "http://localhost:8765/stock", "out", "true" }, {
"seda://dlq", "in", "false" },
+ { "direct://payment", "out", "false" }, { "seda://shipping",
"in", "false" },
+ { "seda://shipping", "out", "false" }, {
"http://localhost:8765/pay", "out", "true" } }) {
+ EndpointInfo endpoint = new EndpointInfo();
+ endpoint.uri = e[0];
+ endpoint.direction = e[1];
+ endpoint.remote = Boolean.parseBoolean(e[2]);
+ info.endpoints.add(endpoint);
+ }
+ ChaosRecipes.Placeholder any =
ChaosRecipes.parse(MODEL).recipes().get(3).placeholders().get(0);
+
+ // the reply of platform-http is not a send, and the dead letter
channel only an error handler sends to
+ assertThat(ChaosRecipes.candidates(any, info)).containsExactly(
+ "http://localhost:8765/stock", "http://localhost:8765/pay",
"direct://payment", "seda://shipping",
+ "direct://inventory", "seda://dlq");
+ }
+
+ @Test
+ void aTimerIsNotOfferedForThePollFaults() {
+ IntegrationInfo info = chaosdemo();
+ ChaosRecipes.Placeholder polled =
ChaosRecipes.parse(MODEL).recipes().get(5).placeholders().get(0);
+ assertThat(ChaosRecipes.candidates(polled,
info)).containsExactly("file://inbox");
+ ConsumerInfo timer = info.consumers.get(0);
+ timer.id = "orders";
+ assertThat(ChaosHere.forConsumer(timer).polled()).isNull();
+ }
+
@Test
void theCatalogOfTheCliHasTheRecipes() {
ChaosRecipes recipes = ChaosRecipes.load();
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 52e87bd580f6..9d74e9be995a 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
@@ -145,6 +145,67 @@ class ChaosTabRenderTest {
assertThat(screen).contains("error status 503 at send
http://localhost:8765/stock").contains("3F2A-1E");
}
+ @Test
+ void theFaultInAFewWordsAndTheEffectOfAPollRule() throws Exception {
+ // CAMEL-26699: as the demo shop had them
+ chaos("""
+ {"enabled": true, "rules": [
+ {"id": "r3", "version": 1, "state": "active",
+ "rule": "endpoint=http://localhost:8765/pay* error
exception=java.net.ConnectException rate=0.5",
+ "hooks": ["send"], "matches": 20, "fired": {"send": 9},
"faultsLastMinute": 9,
+ "effect": {"faulted": 9, "completed": 0, "recovered": 7,
"handled": 0, "failed": 0, "inFlight": 2}},
+ {"id": "r4", "version": 1, "state": "active",
+ "rule": "route=shipping lifecycle action=stop duration=5s
repeat=3 every=20s",
+ "hooks": [], "matches": 2, "fired": {"lifecycle": 2},
"faultsLastMinute": 2},
+ {"id": "r5", "version": 1, "state": "active", "rule":
"endpoint=file://inbox* error at=poll rate=0.5",
+ "hooks": ["poll"], "matches": 80, "fired": {"poll": 39},
"faultsLastMinute": 30}],
+ "report": {"verdict": "PASS", "final": true, "seed": 42}}
+ """);
+ ChaosInfo.Rule pay = info.chaos.rule("r3");
+ assertThat(pay.faultShort()).isEqualTo("error ConnectException");
+ assertThat(info.chaos.rule("r4").faultShort()).isEqualTo("stop 5s ×3
every 20s");
+ ChaosInfo.Rule poll = info.chaos.rule("r5");
+ assertThat(poll.firedAtPoll()).isEqualTo(39);
+
+ String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160,
40);
+ // in flight is not … , which reads as a cut text
+ assertThat(screen).contains("error ConnectException").contains("stop
5s ×3 every 20s").contains("7↩ 2◷")
+ .contains("39 polls failed");
+ }
+
+ @Test
+ void theRecentFaultsOfAnExchangeOnceAndTheRoutesItWasIn() throws Exception
{
+ chaos("""
+ {"enabled": true, "rules": [
+ {"id": "r1", "version": 1, "state": "active", "rule":
"step=reserve error rate=0.6",
+ "hooks": ["step"], "matches": 30, "fired": {"step": 4},
"faultsLastMinute": 4,
+ "effect": {"faulted": 2, "completed": 0, "recovered": 1,
"handled": 1, "failed": 0, "inFlight": 0}}],
+ "report": {"verdict": "PASS", "final": true, "seed": 42,
"recentFaults": [
+ {"ago": 1000, "rule": "r1", "fault": "error
ChaosException", "hook": "step", "route": "inventory",
+ "step": "reserve", "exchangeId":
"98AE31B10677BEA-000000000000286B"},
+ {"ago": 1500, "rule": "r1", "fault": "error
ChaosException", "hook": "step", "route": "inventory",
+ "step": "reserve", "exchangeId":
"98AE31B10677BEA-000000000000286B"},
+ {"ago": 2000, "rule": "r1", "fault": "error
ChaosException", "hook": "step", "route": "inventory",
+ "step": "reserve", "exchangeId":
"98AE31B10677BEA-000000000000286B"},
+ {"ago": 4000, "rule": "r1", "fault": "error
ChaosException", "hook": "step", "route": "inventory",
+ "step": "reserve", "exchangeId":
"98AE31B10677BEA-0000000000002861"}]}}
+ """);
+ RouteInfo inventory = new RouteInfo();
+ inventory.routeId = "inventory";
+ inventory.total = 900;
+ inventory.meanTime = 27;
+ inventory.p95Time = 411;
+ inventory.p99Time = 907;
+ inventory.throughput = "1.69";
+ info.routes.add(inventory);
+
+ assertThat(ChaosTab.recentOf(info.chaos,
info.chaos.rule("r1")).values()).containsExactly(3, 1);
+ String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160,
40);
+ assertThat(screen).contains("98AE31…286B ×3").contains("98AE31…2861")
+ .contains("inventory mean 27ms · p95 411ms · p99 907ms · 1.69
msg/s");
+ assertThat(screen.lines().filter(l ->
l.contains("…286B")).count()).isEqualTo(1);
+ }
+
@Test
void withoutCamelChaosOrWhenItIsOff() throws Exception {
String none = TuiTestHelper.renderToString(new ChaosTab(ctx), 120, 15);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
index ec38d5b61f3d..6b81a39fd80f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfirmationsTest.java
@@ -102,6 +102,45 @@ class ConfirmationsTest {
assertThat(pending).isEmpty();
}
+ @Test
+ void aLoneEscCancelsADialogThatEscOnlyCancels() {
+ // CAMEL-26699: the confirm of a chaos action, which an MCP client
opened; Esc closes it, Enter or y never answer
+ AtomicBoolean chaosConfirm = new AtomicBoolean(true);
+ Confirmations chaos = new Confirmations(pending).add("a confirm dialog
of the tab", chaosConfirm::get, () -> true);
+ pending.add(due(KeyEvent.ofChar('y')));
+ pending.add(due(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.CTRL)));
+ pending.add(due(KeyEvent.ofKey(KeyCode.ESCAPE, KeyModifiers.NONE)));
+ List<String> delivered = new ArrayList<>();
+
+ chaos.deliverDue(0, key -> {
+ delivered.add(key.isKey(KeyCode.ESCAPE) ? "Esc" : key.string());
+ chaosConfirm.set(false);
+ });
+
+ assertThat(delivered).containsExactly("Esc");
+ assertThat(chaos.open()).isNull();
+ // the quit confirmation is not one that Esc only cancels
+ assertThat(confirmations.isOpenCancelledByEsc()).isFalse();
+ }
+
+ @Test
+ void sendKeysLetsALoneEscCancelADialogThatEscOnlyCancels() {
+ AtomicBoolean chaosConfirm = new AtomicBoolean(true);
+ Confirmations chaos = new Confirmations(pending).add("a confirm dialog
of the tab", chaosConfirm::get, () -> true);
+ McpFacade facade = new McpFacade(
+ null, new AtomicReference<>(List.of()), null, new
RecordingManager(null), null, null, null, null, null,
+ null, null, pending, null);
+ facade.setConfirmations(chaos);
+ TuiToolRegistry tools = new TuiToolRegistry(facade);
+
+ assertThat(tools.callSendKeys(Map.of("keys", List.of("y"))))
+ .startsWith("Not sent: a confirm dialog of the tab is
open").endsWith("(a lone Esc cancels it).");
+ assertThat(tools.callSendKeys(Map.of("keys", List.of("Esc",
"y")))).startsWith("Not sent");
+ assertThat(pending).isEmpty();
+ assertThat(tools.callSendKeys(Map.of("keys",
List.of("Esc")))).startsWith("Queued 1 key");
+ assertThat(pending).hasSize(1);
+ }
+
@Test
void whileATapeDrivesTheMonitorNothingAsksToConfirm() {
MonitorContext ctx = new MonitorContext(new
AtomicReference<>(List.of()), new AtomicReference<>(List.of()));