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 ea4ec7bf80ff CAMEL-26695: camel-jbang - chaos screen: add, change,
remove and clear rules, and a new experiment (#29346)
ea4ec7bf80ff is described below
commit ea4ec7bf80ffa921fa28071c07734e706cdc199b
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 17:16:03 2026 +0100
CAMEL-26695: camel-jbang - chaos screen: add, change, remove and clear
rules, and a new experiment (#29346)
Signed-off-by: Claus Ibsen <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../ROOT/images/jbang/camel-tui-chaos-add.png | Bin 0 -> 693973 bytes
.../ROOT/pages/camel-jbang-tui-observe.adoc | 17 +-
.../dsl/jbang/core/commands/tui/ChaosControl.java | 432 +++++++++++++++++++++
.../dsl/jbang/core/commands/tui/ChaosTab.java | 63 ++-
.../src/main/resources/tui/help/chaos.md | 16 +-
.../jbang/core/commands/tui/ChaosControlTest.java | 231 +++++++++++
6 files changed, 753 insertions(+), 6 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-add.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-add.png
new file mode 100644
index 000000000000..bdea1e03c3f3
Binary files /dev/null and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-add.png differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
index b8d1566b82e0..be83c83f8c23 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
@@ -258,9 +258,20 @@ recent faults with the endpoint or step, the route and the
exchange.
image::jbang/camel-tui-chaos.png[The Chaos tab: a failed expectation, four
rules with their live state, and the recent faults of the selected rule]
While a rule injects faults, the title bar shows a badge on every screen (`๐ฒ
chaos 3 rules ยท FAIL`), so a failure made
-on purpose is not debugged as a real one. The tab only reads: add, change and
remove rules with `camel cmd chaos-add`,
-`chaos-change`, `chaos-remove` and `chaos-clear` (see
-xref:next@components:others:chaos.adoc#_control_while_it_runs[Control while it
runs]).
+on purpose is not debugged as a real one.
+
+The rules are added, changed and removed on the tab, as with `camel cmd
chaos-add`, `chaos-change`, `chaos-remove` and
+`chaos-clear` (see
xref:next@components:others:chaos.adoc#_control_while_it_runs[Control while it
runs]):
+
+* *a* adds a rule, typed as one line (`endpoint=kafka:orders* error rate=0.1
until=5m`), and *e* changes the selected
+rule, starting from the rule as it is.
+* *x* removes the selected rule, *c* removes all rules (the report keeps what
they did), and *R* starts a new experiment:
+no rules and a new report, with the same seed.
+
+Each asks to confirm with *y*, with the rule shown. A rule that is not valid
comes back with the reason, and a rule that
+matches nothing in the integration (often a typo) with a warning; press *e*
there to change it.
+
+image::jbang/camel-tui-chaos-add.png[Adding a chaos rule on the Chaos tab: the
rule is shown before it is added]
=== CVE Audit
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
new file mode 100644
index 000000000000..20b13bfab658
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
@@ -0,0 +1,432 @@
+/*
+ * 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.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Overflow;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.input.TextInputState;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * The actions of the chaos screen, through the {@code chaos} action of the
CLI connector as {@code camel cmd chaos-*}
+ * sends it: add a rule ({@code a}) or change the selected one ({@code e})
typed as a one-line rule, remove the selected
+ * rule ({@code x}), clear all rules ({@code c}) and start a new experiment
({@code R}, a clear that also resets the
+ * report). Each asks to confirm, with the rule shown; only the user answers
({@link Confirmations}), and while a tape
+ * drives the monitor nothing asks ({@link MonitorContext#confirmFirst()}).
The connector checks the rule as
+ * {@code camel.chaos.rules} is checked: a rule that is not valid comes back
with the reason, and a rule that matches
+ * nothing in the integration with its warnings, shown in a dialog from which
the rule can be changed.
+ */
+final class ChaosControl {
+
+ /** What the control shows: nothing, the rule being typed, the question,
or the outcome to read. */
+ enum Step {
+ NONE,
+ INPUT,
+ CONFIRM,
+ RESULT
+ }
+
+ /** An action on the rules. */
+ enum Command {
+ ADD,
+ CHANGE,
+ REMOVE,
+ CLEAR,
+ RESET
+ }
+
+ private final MonitorContext ctx;
+ // sends the request of the chaos action to the integration and returns
its reply (null when it did not answer)
+ private final Function<JsonObject, JsonObject> sender;
+ // set by the background task that sends the action, read when the screen
is drawn
+ private volatile Step step = Step.NONE;
+ private Command command;
+ private String pid;
+ private String name;
+ private String id;
+ private String rule;
+ private TextInputState input;
+ private String resultTitle;
+ private final List<String> resultLines = new ArrayList<>();
+ private boolean resultError;
+
+ ChaosControl(MonitorContext ctx) {
+ this(ctx, null);
+ }
+
+ ChaosControl(MonitorContext ctx, Function<JsonObject, JsonObject> sender) {
+ this.ctx = ctx;
+ this.sender = sender;
+ }
+
+ Step step() {
+ return step;
+ }
+
+ boolean isTextInputActive() {
+ return step == Step.INPUT;
+ }
+
+ boolean isConfirming() {
+ return step == Step.CONFIRM;
+ }
+
+ boolean isOpen() {
+ return step != Step.NONE;
+ }
+
+ /**
+ * Handles a key of the chaos screen: the keys of an open dialog, else the
action keys.
+ *
+ * @param info the selected integration
+ * @param selected the selected rule, or null
+ * @return whether the key was used
+ */
+ boolean handleKey(KeyEvent ke, IntegrationInfo info, ChaosInfo.Rule
selected) {
+ switch (step) {
+ case INPUT -> {
+ return handleInputKey(ke);
+ }
+ case CONFIRM -> {
+ if (ke.isChar('y') || ke.isChar('Y')) {
+ execute();
+ } else if (ke.isCancel() || ke.isChar('n') || ke.isChar('N')) {
+ close();
+ }
+ // the question waits for its answer: no other key goes through
+ return true;
+ }
+ case RESULT -> {
+ if (ke.isChar('e') && canChange()) {
+ // type the rule again: a refused rule as it was, a rule
that matches nothing as a change of it
+ openInput(command, rule);
+ } else if (ke.isCancel() || ke.isConfirm()) {
+ close();
+ }
+ return true;
+ }
+ default -> {
+ return handleActionKey(ke, info, selected);
+ }
+ }
+ }
+
+ private boolean handleActionKey(KeyEvent ke, IntegrationInfo info,
ChaosInfo.Rule selected) {
+ if (info == null || info.chaos == null || !info.chaos.enabled) {
+ return false;
+ }
+ pid = info.pid;
+ name = info.name;
+ if (ke.isChar('a')) {
+ id = null;
+ openInput(Command.ADD, "");
+ return true;
+ }
+ if (selected != null && selected.id != null && ke.isChar('e')) {
+ id = selected.id;
+ openInput(Command.CHANGE, selected.text);
+ return true;
+ }
+ if (selected != null && selected.id != null && ke.isChar('x')) {
+ id = selected.id;
+ rule = selected.text;
+ ask(Command.REMOVE);
+ return true;
+ }
+ if (ke.isChar('c') && !info.chaos.rules.isEmpty()) {
+ ask(Command.CLEAR);
+ return true;
+ }
+ if (ke.isChar('R')) {
+ ask(Command.RESET);
+ return true;
+ }
+ return false;
+ }
+
+ private void openInput(Command what, String text) {
+ command = what;
+ input = new TextInputState(text != null ? text : "");
+ input.moveCursorToEnd();
+ step = Step.INPUT;
+ }
+
+ private boolean handleInputKey(KeyEvent ke) {
+ if (ke.isCancel()) {
+ close();
+ } else if (ke.isConfirm()) {
+ String text = input.text().strip();
+ if (!text.isEmpty()) {
+ rule = text;
+ ask(command);
+ }
+ } else {
+ FormHelper.handleTextInput(ke, input);
+ }
+ return true;
+ }
+
+ /** Pastes into the rule being typed. */
+ boolean handlePaste(String text) {
+ if (step != Step.INPUT || text == null) {
+ return false;
+ }
+ // a rule is one line
+ input.insert(text.replace('\n', ' ').replace('\r', ' '));
+ return true;
+ }
+
+ private void ask(Command what) {
+ command = what;
+ if (ctx.confirmFirst()) {
+ step = Step.CONFIRM;
+ } else {
+ execute();
+ }
+ }
+
+ /** Esc: closes the open dialog. */
+ boolean escape() {
+ if (step == Step.NONE) {
+ return false;
+ }
+ close();
+ return true;
+ }
+
+ private void close() {
+ step = Step.NONE;
+ input = null;
+ }
+
+ /** The request of the chaos action for the command, as {@code camel cmd
chaos-*} sends it. */
+ JsonObject request() {
+ JsonObject root = new JsonObject();
+ root.put("action", "chaos");
+ switch (command) {
+ case ADD -> {
+ root.put("command", "add");
+ root.put("rule", rule);
+ }
+ case CHANGE -> {
+ root.put("command", "change");
+ root.put("id", id);
+ root.put("rule", rule);
+ }
+ case REMOVE -> {
+ root.put("command", "remove");
+ root.put("id", id);
+ }
+ case CLEAR -> root.put("command", "clear");
+ case RESET -> {
+ root.put("command", "clear");
+ root.put("reset", true);
+ }
+ default -> throw new IllegalStateException("Unknown command " +
command);
+ }
+ return root;
+ }
+
+ /** Sends the action in the background (the integration answers within
seconds), and shows how it went. */
+ private void execute() {
+ JsonObject request = request();
+ String target = pid;
+ step = Step.NONE;
+ Runnable task = () -> {
+ JsonObject reply = sender != null ? sender.apply(request) :
ctx.executeAction(target, request, 5000);
+ outcome(reply);
+ };
+ if (sender != null) {
+ // the tests answer at once
+ task.run();
+ } else {
+ ctx.backgroundExecutor.execute(task);
+ }
+ }
+
+ /** How the action went: a notification, or a dialog with the reason of a
refusal or the warnings of the rule. */
+ void outcome(JsonObject reply) {
+ resultLines.clear();
+ if (reply == null) {
+ result("Chaos", true, "The integration " + name + " did not
answer");
+ return;
+ }
+ String error = reply.getString("error");
+ if (error != null || "failed".equals(reply.getString("status"))) {
+ result(command == Command.CHANGE ? "Chaos rule not changed" :
"Chaos rule not added", true,
+ error != null ? error : "The integration refused it");
+ return;
+ }
+ String done = switch (command) {
+ case ADD -> "Added chaos rule " + idsOf(reply);
+ case CHANGE -> "Changed chaos rule " + id + " (version " +
reply.get("version") + ")";
+ case REMOVE -> "Removed chaos rule " + id;
+ case CLEAR -> "Removed all chaos rules";
+ case RESET -> "New chaos experiment: no rules, a new report";
+ };
+ if (reply.get("warnings") instanceof List<?> warnings &&
!warnings.isEmpty()) {
+ List<String> lines = new ArrayList<>();
+ lines.add(done + ", with a warning: it matches nothing in " + name
+ " now (a typo?)");
+ warnings.forEach(w -> lines.add(" " + w));
+ if (command == Command.ADD) {
+ // change the rule that was just added
+ id = reply.getString("id");
+ }
+ command = command == Command.ADD && id != null ? Command.CHANGE :
command;
+ result("Chaos rule matches nothing", false, lines.toArray(new
String[0]));
+ return;
+ }
+ if (ctx.notificationCallback != null) {
+ ctx.notificationCallback.accept(done, false);
+ }
+ }
+
+ private static String idsOf(JsonObject reply) {
+ if (reply.getString("id") != null) {
+ return reply.getString("id");
+ }
+ return reply.get("ids") instanceof List<?> ids ? String.join(", ",
ids.stream().map(String::valueOf).toList()) : "";
+ }
+
+ private void result(String title, boolean error, String... lines) {
+ resultTitle = title;
+ resultError = error;
+ resultLines.clear();
+ resultLines.addAll(List.of(lines));
+ step = Step.RESULT;
+ }
+
+ String resultText() {
+ return String.join("\n", resultLines);
+ }
+
+ // ---- rendering ----
+
+ void render(Frame frame, Rect area) {
+ switch (step) {
+ case INPUT -> DialogHelper.renderInputDialog(frame, area,
+ command == Command.CHANGE ? "Change chaos rule " + id :
"Add a chaos rule to " + name, input,
+ "endpoint=kafka:orders* error rate=0.1 until=5m");
+ case CONFIRM -> DialogHelper.renderConfirm(frame, area, title(),
question(), detail(), "y", "yes", null, null);
+ case RESULT -> renderResult(frame, area);
+ default -> {
+ // nothing open
+ }
+ }
+ }
+
+ private String title() {
+ return switch (command) {
+ case ADD -> "Add Chaos Rule";
+ case CHANGE -> "Change Chaos Rule";
+ case REMOVE -> "Remove Chaos Rule";
+ case CLEAR -> "Clear Chaos Rules";
+ case RESET -> "New Chaos Experiment";
+ };
+ }
+
+ /** The question of the confirmation. */
+ String question() {
+ return switch (command) {
+ case ADD -> "Add this chaos rule to " + name + "? It injects
faults while it runs";
+ case CHANGE -> "Change chaos rule " + id + " of " + name + " to
this rule?";
+ case REMOVE -> "Remove chaos rule " + id + " from " + name + "?";
+ case CLEAR -> "Remove all chaos rules from " + name + "? The
report keeps what they did";
+ case RESET -> "Start a new chaos experiment in " + name + "? No
rules, and a new report (same seed)";
+ };
+ }
+
+ private String detail() {
+ return switch (command) {
+ case ADD, CHANGE, REMOVE -> rule;
+ default -> null;
+ };
+ }
+
+ private void renderResult(Frame frame, Rect area) {
+ Style accent = resultError ? Theme.error() : Theme.warning();
+ int width = Math.max(40, Math.min(area.width() - 4, 110));
+ List<Line> lines = new ArrayList<>();
+ lines.add(Line.empty());
+ for (String text : resultLines) {
+ lines.add(Line.from(Span.styled(" " + text, text.startsWith(" ")
? Theme.muted() : accent)));
+ }
+ if (rule != null && command != Command.REMOVE) {
+ lines.add(Line.empty());
+ lines.add(Line.from(Span.styled(" " + rule, Theme.muted())));
+ }
+ lines.add(Line.empty());
+ lines.add(canChange()
+ ? TuiHelper.hintLine("e", "change it", "Esc", "close")
+ : TuiHelper.hintLine("Esc", "close"));
+ // the reason of a refusal can be long: it wraps
+ int height
+ = Math.min(area.height() - 2, lines.size() + 2 +
resultLines.stream().mapToInt(l -> l.length() / width).sum());
+ Rect popup = DialogHelper.centered(area, width, Math.max(6, height));
+ frame.renderWidget(Clear.INSTANCE, popup);
+ frame.renderWidget(Paragraph.builder()
+ .text(Text.from(lines))
+ .overflow(Overflow.WRAP_WORD)
+
.block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).borderStyle(accent)
+ .title(" " + resultTitle + " ").build())
+ .build(), popup);
+ }
+
+ private boolean canChange() {
+ return rule != null && (command == Command.ADD || command ==
Command.CHANGE);
+ }
+
+ /** The keys of the screen, or of the open dialog, for the footer. */
+ void renderFooter(List<Span> spans, boolean hasRules) {
+ switch (step) {
+ case INPUT -> {
+ TuiHelper.hint(spans, "Enter", "confirm");
+ TuiHelper.hint(spans, "Esc", "cancel");
+ }
+ case CONFIRM -> {
+ TuiHelper.hint(spans, "y", "yes");
+ TuiHelper.hint(spans, "Esc", "no");
+ }
+ case RESULT -> TuiHelper.hint(spans, "Esc", "close");
+ default -> {
+ TuiHelper.hint(spans, "Esc", "back");
+ TuiHelper.hint(spans, "a", "add");
+ if (hasRules) {
+ TuiHelper.hint(spans, "e", "change");
+ TuiHelper.hint(spans, "x", "remove");
+ TuiHelper.hint(spans, "c", "clear");
+ }
+ TuiHelper.hint(spans, "R", "new experiment");
+ }
+ }
+ }
+}
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 d14f906f0b89..38a4e4ae630e 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
@@ -29,6 +29,7 @@ import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.widgets.block.Block;
import dev.tamboui.widgets.block.BorderType;
import dev.tamboui.widgets.block.Borders;
@@ -46,7 +47,8 @@ import static
org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
* The chaos screen (camel-chaos): whether chaos is on, the verdict of the
report, the current rules with their live
* state (the observed rate, when a rule last fired, its faults per second
over the last minute as a sparkline, and the
* effect of its faults on the exchanges), the selected rule in detail with
its recent faults, and the counts per route
- * of the report. It only reads: rules are added, changed and removed with
{@code camel cmd chaos-*}.
+ * of the report. Rules are added, changed and removed from here too ({@link
ChaosControl}), as with
+ * {@code camel cmd chaos-*}.
*/
class ChaosTab extends AbstractTableTab {
@@ -55,8 +57,59 @@ class ChaosTab extends AbstractTableTab {
private static final int TABLE_SPARK = 15;
private static final int ROUTES_WIDTH = 46;
+ private final ChaosControl control;
+
ChaosTab(MonitorContext ctx) {
+ this(ctx, new ChaosControl(ctx));
+ }
+
+ ChaosTab(MonitorContext ctx, ChaosControl control) {
super(ctx);
+ this.control = control;
+ }
+
+ ChaosControl control() {
+ return control;
+ }
+
+ @Override
+ protected boolean handleTabKeyEvent(KeyEvent ke) {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ return control.handleKey(ke, info, info != null && info.chaos != null
? selectedRule(info.chaos) : null);
+ }
+
+ @Override
+ public boolean handleEscape() {
+ return control.escape();
+ }
+
+ @Override
+ public boolean isTextInputActive() {
+ return control.isTextInputActive();
+ }
+
+ @Override
+ public boolean isConfirming() {
+ return control.isConfirming();
+ }
+
+ @Override
+ public boolean handlePaste(String text) {
+ return control.handlePaste(text);
+ }
+
+ @Override
+ public void navigateUp() {
+ if (!control.isOpen()) {
+ super.navigateUp();
+ }
+ }
+
+ @Override
+ public void navigateDown() {
+ if (!control.isOpen()) {
+ super.navigateDown();
+ }
}
@Override
@@ -94,6 +147,7 @@ class ChaosTab extends AbstractTableTab {
.split(rows.get(2));
renderDetail(frame, bottom.get(0), chaos, selectedRule(chaos));
renderRoutes(frame, bottom.get(1), chaos);
+ control.render(frame, area);
}
private ChaosInfo.Rule selectedRule(ChaosInfo chaos) {
@@ -596,7 +650,12 @@ class ChaosTab extends AbstractTableTab {
@Override
public void renderFooter(List<Span> spans) {
- hint(spans, "Esc", "back");
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ if (info == null || info.chaos == null || !info.chaos.enabled) {
+ hint(spans, "Esc", "back");
+ return;
+ }
+ control.renderFooter(spans, !info.chaos.rules.isEmpty());
}
@Override
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 fa746aed9035..a8effd4f8136 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
@@ -6,7 +6,7 @@ for 20 seconds". This screen shows the rules, how often they
fire, what
their faults do to the exchanges, and the verdict of the report.
Chaos is on with `camel run --chaos` (profile `dev`). Add, change and
-remove rules with the Camel CLI:
+remove rules on this screen (see Keys below), or with the Camel CLI:
```
camel cmd chaos-add myapp --rule='endpoint=http:stock* error status=503
rate=0.25 until=10m'
@@ -52,3 +52,17 @@ exchange.
The counts of the report per route: completed (DONE), failed (FAIL),
failed and kept by the consumer to read again (KEPT), handled by the
error handler (HAND) and the redeliveries (REDEL).
+
+## Keys
+
+- **a** โ Add a rule, typed as one line, such as `endpoint=kafka:orders* error
rate=0.1 until=5m`
+- **e** โ Change the selected rule: the line starts with the rule as it is
+- **x** โ Remove the selected rule
+- **c** โ Clear: remove all rules (the report keeps what they did)
+- **R** โ A new experiment: no rules and a new report, with the same seed
+
+Each asks to confirm with **y**, showing the rule. The integration checks
+the rule as `camel.chaos.rules` is checked: a rule that is not valid comes
+back with the reason, and a rule that matches nothing in the integration
+(often a typo) with a warning. Press **e** in that dialog to change the
+rule.
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControlTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControlTest.java
new file mode 100644
index 000000000000..7d376d3539cb
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControlTest.java
@@ -0,0 +1,231 @@
+/*
+ * 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.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+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.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The actions of the chaos screen ({@link ChaosControl}): the keys, the
confirmation, the request sent to the chaos
+ * action of the CLI connector, and how the reply is shown.
+ */
+class ChaosControlTest {
+
+ private MonitorContext ctx;
+ private IntegrationInfo info;
+ private final List<JsonObject> requests = new ArrayList<>();
+ private final List<String> notifications = new ArrayList<>();
+ private JsonObject reply;
+ private ChaosTab tab;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ Theme.resetForTesting();
+ info = new IntegrationInfo();
+ info.pid = "1234";
+ info.name = "chaosdemo";
+ info.chaos = ChaosInfo.parse((JsonObject) Jsoner.deserialize("""
+ {"enabled": true,
+ "rules": [{"id": "r1", "version": 1, "state": "active",
"rule": "endpoint=http:stock* error status=503"}],
+ "report": {"verdict": "PASS", "final": true, "seed": 42}}
+ """));
+ ctx = new MonitorContext(new AtomicReference<>(List.of(info)), new
AtomicReference<>(List.of()));
+ ctx.selectedPid = "1234";
+ ctx.confirmActions = true;
+ ctx.notificationCallback = (message, error) ->
notifications.add(message);
+ reply = new JsonObject();
+ tab = new ChaosTab(ctx, new ChaosControl(ctx, request -> {
+ requests.add(request);
+ return reply;
+ }));
+ // the first rule is selected, as the screen does when it is drawn
+ TuiTestHelper.renderToString(tab, 160, 40);
+ }
+
+ private void key(char ch) {
+ tab.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+ }
+
+ private void type(String text) {
+ for (char ch : text.toCharArray()) {
+ key(ch);
+ }
+ }
+
+ private void enter() {
+ tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ }
+
+ @Test
+ void addARuleTypedAsOneLineAfterTheConfirmation() {
+ key('a');
+ assertThat(tab.isTextInputActive()).isTrue();
+ assertThat(TuiTestHelper.renderToString(tab, 160, 40)).contains("Add a
chaos rule to chaosdemo");
+ type("step=enrich delay duration=1s");
+ enter();
+
+ assertThat(tab.isConfirming()).isTrue();
+ assertThat(requests).isEmpty();
+ assertThat(TuiTestHelper.renderToString(tab, 160, 40))
+ .contains("Add this chaos rule to
chaosdemo?").contains("step=enrich delay duration=1s");
+
+ reply.put("id", "r2");
+ key('y');
+ assertThat(requests).hasSize(1);
+ JsonObject request = requests.get(0);
+ assertThat(request.getString("action")).isEqualTo("chaos");
+ assertThat(request.getString("command")).isEqualTo("add");
+ assertThat(request.getString("rule")).isEqualTo("step=enrich delay
duration=1s");
+ assertThat(notifications).containsExactly("Added chaos rule r2");
+ assertThat(tab.control().isOpen()).isFalse();
+ }
+
+ @Test
+ void changeTheSelectedRuleStartsFromItsText() {
+ key('e');
+ assertThat(TuiTestHelper.renderToString(tab, 160,
40)).contains("Change chaos rule r1")
+ .contains("endpoint=http:stock* error status=503");
+ type(" rate=0.5");
+ enter();
+ reply.put("id", "r1");
+ reply.put("version", 2);
+ key('y');
+
+ assertThat(requests.get(0).getString("command")).isEqualTo("change");
+ assertThat(requests.get(0).getString("id")).isEqualTo("r1");
+
assertThat(requests.get(0).getString("rule")).isEqualTo("endpoint=http:stock*
error status=503 rate=0.5");
+ assertThat(notifications).containsExactly("Changed chaos rule r1
(version 2)");
+ }
+
+ @Test
+ void removeClearAndANewExperiment() {
+ key('x');
+ assertThat(TuiTestHelper.renderToString(tab, 160,
40)).contains("Remove chaos rule r1 from chaosdemo?");
+ key('y');
+ key('c');
+ key('y');
+ key('R');
+ assertThat(TuiTestHelper.renderToString(tab, 160, 40)).contains("Start
a new chaos experiment in chaosdemo?");
+ key('y');
+
+ assertThat(requests).extracting(r ->
r.getString("command")).containsExactly("remove", "clear", "clear");
+ assertThat(requests.get(0).getString("id")).isEqualTo("r1");
+ assertThat(requests.get(1).containsKey("reset")).isFalse();
+ assertThat(requests.get(2).getBoolean("reset")).isTrue();
+ assertThat(notifications).containsExactly("Removed chaos rule r1",
"Removed all chaos rules",
+ "New chaos experiment: no rules, a new report");
+ }
+
+ @Test
+ void escOrNoSendsNothing() {
+ key('x');
+ assertThat(tab.handleEscape()).isTrue();
+ key('c');
+ key('n');
+ key('a');
+ type("step=a error");
+ assertThat(tab.handleEscape()).isTrue();
+
+ assertThat(requests).isEmpty();
+ assertThat(tab.control().isOpen()).isFalse();
+ // Esc with nothing open goes back, as on the other screens
+ assertThat(tab.handleEscape()).isFalse();
+ }
+
+ @Test
+ void aRefusedRuleShowsTheReasonAndCanBeChanged() {
+ reply.put("status", "failed");
+ reply.put("error", "Invalid chaos rule [step=a drop]: the fault drop
is not offered at any hook of the target step=");
+ key('a');
+ type("step=a drop");
+ enter();
+ key('y');
+
+ assertThat(tab.control().step()).isEqualTo(ChaosControl.Step.RESULT);
+ assertThat(TuiTestHelper.renderToString(tab, 160, 40)).contains("Chaos
rule not added")
+ .contains("the fault drop is not offered");
+ assertThat(notifications).isEmpty();
+
+ // e: type it again, from the rule that was refused
+ key('e');
+ assertThat(tab.isTextInputActive()).isTrue();
+ assertThat(TuiTestHelper.renderToString(tab, 160,
40)).contains("step=a drop");
+ }
+
+ @Test
+ void aRuleThatMatchesNothingShowsItsWarningsAndIsChangedFromThere() {
+ reply.put("id", "r2");
+ reply.put("warnings", new JsonArray(List.of("r2: endpoint=htp:stock*
matches no endpoint of the context")));
+ key('a');
+ type("endpoint=htp:stock* error");
+ enter();
+ key('y');
+
+ assertThat(TuiTestHelper.renderToString(tab, 160, 40)).contains("Chaos
rule matches nothing")
+ .contains("Added chaos rule r2, with a
warning").contains("matches no endpoint");
+ // e changes the rule that was added
+ key('e');
+ type("x");
+ enter();
+ key('y');
+ assertThat(requests.get(1).getString("command")).isEqualTo("change");
+ assertThat(requests.get(1).getString("id")).isEqualTo("r2");
+ }
+
+ @Test
+ void withoutConfirmActionsItDoesNotAsk() {
+ ctx.confirmActions = false;
+ key('x');
+ assertThat(requests).hasSize(1);
+ assertThat(tab.isConfirming()).isFalse();
+ }
+
+ @Test
+ void theKeysAreOnlyThereWhenChaosIsOn() throws Exception {
+ info.chaos = ChaosInfo.parse((JsonObject)
Jsoner.deserialize("{\"enabled\": false}"));
+ key('a');
+ assertThat(tab.control().isOpen()).isFalse();
+ List<Span> footer = new ArrayList<>();
+ tab.renderFooter(footer);
+ assertThat(footer.stream().map(Span::content).reduce("",
String::concat)).doesNotContain("add");
+ }
+
+ @Test
+ void theFooterHasTheKeys() {
+ List<Span> footer = new ArrayList<>();
+ tab.renderFooter(footer);
+ assertThat(footer.stream().map(Span::content).reduce("",
String::concat))
+
.contains("add").contains("change").contains("remove").contains("clear").contains("new
experiment");
+ key('x');
+ footer.clear();
+ tab.renderFooter(footer);
+ assertThat(footer.stream().map(Span::content).reduce("",
String::concat)).contains("yes").contains("no");
+ }
+}