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");
+    }
+}

Reply via email to