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 b7694cdd985a CAMEL-26690: camel-jbang - AI tools: a refused Citrus 
test gets the send and receive actions and the body to write (#29323)
b7694cdd985a is described below

commit b7694cdd985ad486de47511fb292ec59d5dafd44
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 16:32:00 2026 +0100

    CAMEL-26690: camel-jbang - AI tools: a refused Citrus test gets the send 
and receive actions and the body to write (#29323)
    
    CAMEL-26690: camel-jbang - AI tools: a Citrus test refused for its http 
action or its body gets the lines to write, and the action that starts the 
integration
    
    The request and its status written straight under http: (every write of
    test-citrus-orders step 1 in r26 and r28 of the local-model benchmark) is 
answered
    with the two actions of a client, sendRequest and receiveResponse, with the 
values as
    written and checked against the test schema. A body written as text, or 
with text:,
    equal:, equalTo: or contains: (step 2), goes under data:, with the jsonPath 
check of
    the response. The refusal no longer points to camel_catalog_examples, which 
is not a
    core tool; a test that calls the integration without starting it gets the 
camel cli
    run action of the project's route file instead.
    
    
    Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../dsl/jbang/core/commands/ai/AuthoringTools.java |  22 +-
 .../jbang/core/commands/ai/CitrusTestChecks.java   | 686 ++++++++++++++++++++-
 .../jbang/core/commands/ai/SourceValidator.java    |   2 +-
 .../core/commands/ai/AuthoringToolsCitrusTest.java |  51 +-
 .../core/commands/ai/CitrusTestChecksTest.java     | 302 ++++++++-
 5 files changed, 1036 insertions(+), 27 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index 0546cb42e9d0..2b77c8cce127 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -661,12 +661,13 @@ public final class AuthoringTools {
                 result.put("notChecked", notChecked);
             }
         }
+        String start = startNote(dir, file, content);
         if (!errors.isEmpty()) {
             result.put("message", errors.size() + " problem(s) found; fix them 
before writing the file"
-                                  + (SourceValidator.isCitrusTest(file) ? ". " 
+ CITRUS_SAMPLES : ""));
+                                  + (start.isEmpty() ? "" : "." + start));
         } else if (SourceValidator.isCitrusTest(file)) {
             result.put("message", (CitrusTestChecks.hasSchema() ? "The test is 
valid. " : "The YAML is valid. ")
-                                  + citrusTestNote(file));
+                                  + citrusTestNote(file) + start);
         } else {
             result.put("message", "The source is valid");
         }
@@ -674,11 +675,14 @@ public final class AuthoringTools {
     }
 
     /**
-     * Where a model finds a Citrus test that runs, said when its own test is 
refused: a local model wrote Citrus YAML
-     * of its own (test-citrus-orders of the local-model benchmark).
+     * What is said of a Citrus test that calls the integration but does not 
start it, with a space before it, or
+     * nothing: no test a local model wrote for test-citrus-orders of the 
local-model benchmark started the integration,
+     * and camel test run runs only the test.
      */
-    static final String CITRUS_SAMPLES
-            = "A sample Citrus test is the test/ file of an example: 
camel_catalog_examples with filter citrus lists them.";
+    private static String startNote(Path dir, String file, String content) {
+        String note = SourceValidator.isCitrusTest(file) ? 
CitrusTestChecks.startNote(content, dir, file) : "";
+        return note.isEmpty() ? "" : " " + note;
+    }
 
     /**
      * What the tools say of a Citrus test: it is not a route, so it is 
checked against the test schema of Citrus (or
@@ -778,7 +782,7 @@ public final class AuthoringTools {
                                   + " Change what the errors say; where an 
error shows the lines to write, copy them.");
         } else {
             result.put("message", "The file was not changed: the result has 
validation errors. Fix them and call"
-                                  + " camel_edit_file again.");
+                                  + " camel_edit_file again." + startNote(dir, 
file, content));
         }
         if ("invalid".equals(result.getString("status")) && 
result.get("repairable") instanceof JsonArray repairable) {
             result.put("message", result.getString("message") + " "
@@ -1751,7 +1755,7 @@ public final class AuthoringTools {
                 result.put("file", file);
                 result.put("errors", new JsonArray(errors));
                 result.put("message", "The file was not written: the content 
has validation errors. Fix them and"
-                                      + " call camel_write_file again." + 
(citrusTest ? " " + CITRUS_SAMPLES : ""));
+                                      + " call camel_write_file again." + 
startNote(dir, file, content));
                 int times = refusedAgain(path, content);
                 if (times > 1) {
                     // the same content again: a model that does not see the 
problem in its own lines writes them
@@ -1903,7 +1907,7 @@ public final class AuthoringTools {
                 default -> "Written; " + reload.getString("message");
             });
         } else if (citrusTest) {
-            result.put("message", citrusTestNote(file));
+            result.put("message", citrusTestNote(file) + startNote(dir, file, 
content));
         } else if (notWatchedBy >= 0) {
             result.put("message", "The running integration (pid " + 
notWatchedBy + ") was started with its own files"
                                   + " and does not reload when this file 
changes; camel_control restart picks up the"
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecks.java
index 29d2e21bf8f3..8b4bbf2924a3 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecks.java
@@ -19,8 +19,11 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
@@ -31,12 +34,15 @@ import java.util.Set;
 import java.util.StringJoiner;
 import java.util.function.BiConsumer;
 import java.util.function.Predicate;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Stream;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.MissingNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
 import com.networknt.schema.Error;
@@ -82,6 +88,23 @@ public final class CitrusTestChecks {
     /** The words SnakeYAML, which Citrus reads a test with, takes as a 
boolean. */
     private static final Set<String> BOOLEAN_WORDS = Set.of("true", "false", 
"yes", "no", "on", "off");
 
+    // the keys local models wrote under http: for the parts of a request and 
its response, which an http action does
+    // not take (every write of test-citrus-orders step 1 in r26 and r28 of 
the local-model benchmark)
+    private static final Set<String> URL_KEYS = Set.of("uri", "url", 
"endpoint", "endpointUri", "address");
+    private static final Set<String> METHOD_KEYS = Set.of("method", 
"httpMethod", "verb", "requestMethod");
+    private static final Set<String> FILE_KEYS = Set.of("requestFile", "file", 
"bodyFile", "payloadFile",
+            "requestBodyFile", "resource");
+    private static final Set<String> BODY_KEYS = Set.of("body", "payload", 
"requestBody", "data", "content");
+    private static final Set<String> STATUS_KEYS = Set.of("status", 
"statusCode", "expectStatus", "expectedStatus",
+            "expectedStatusCode", "expectStatusCode", "responseCode", 
"responseStatus", "code");
+    private static final Set<String> RESPONSE_BODY_KEYS = 
Set.of("expectedBody", "expectBody", "responseBody",
+            "expectedResponse", "expectResponse", "expectedResponseBody");
+    /** The keys a model wrote under a body for a part of the text it expects 
(test-citrus-orders step 2). */
+    private static final Set<String> CONTAINS_KEYS = Set.of("contains", 
"containsString", "includes", "include",
+            "matches", "match", "regex", "pattern", "like", "startsWith", 
"endsWith");
+    /** A URL: the client part (scheme, host and port), and the path. */
+    private static final Pattern URL = 
Pattern.compile("^([A-Za-z][\\w+.-]*://[^/?#]+)(.*)$");
+
     private static volatile TestSchema schema;
 
     private CitrusTestChecks() {
@@ -109,6 +132,17 @@ public final class CitrusTestChecks {
      * parse (the YAML check says where), and when there is no schema.
      */
     public static List<String> validate(String content) {
+        return validate(content, null, null);
+    }
+
+    /**
+     * As {@link #validate(String)}, with where the test is: a file the lines 
to write name is given relative to the
+     * test, as Citrus reads it.
+     *
+     * @param dir  the project directory, or null
+     * @param file the test file, relative to the directory
+     */
+    public static List<String> validate(String content, Path dir, String file) 
{
         if (content == null || content.isBlank()) {
             return List.of();
         }
@@ -125,7 +159,9 @@ public final class CitrusTestChecks {
         if (tree == null || tree.isMissingNode()) {
             return List.of();
         }
-        return new Check(s, content, tree).problems();
+        Path project = dir != null ? dir.toAbsolutePath().normalize() : null;
+        Path testDir = project != null && file != null ? 
project.resolve(file).normalize().getParent() : null;
+        return new Check(s, content, tree, project, testDir).problems();
     }
 
     /** Whether a test is checked against the test schema; false when it is 
checked for its YAML syntax only. */
@@ -341,8 +377,14 @@ public final class CitrusTestChecks {
         private final TestSchema schema;
         private final JsonNode tree;
         private final Node root;
+        private final String[] lines;
+        /** The project directory and the directory of the test, or null when 
they are not known. */
+        private final Path project;
+        private final Path testDir;
         /** The action of each action item, by its pointer (/actions/0: http), 
for the path a message names. */
         private final Map<String, String> items = new LinkedHashMap<>();
+        /** The YAML node of each action item, by its pointer, for the lines 
of the item. */
+        private final Map<String, Node> itemNodes = new HashMap<>();
         /** The pointers where an action is expected: the items of the action 
lists. */
         private final Set<String> slots = new HashSet<>();
         private final List<Problem> problems = new ArrayList<>();
@@ -350,10 +392,25 @@ public final class CitrusTestChecks {
         private record Problem(int line, String text) {
         }
 
-        Check(TestSchema schema, String content, JsonNode tree) {
+        Check(TestSchema schema, String content, JsonNode tree, Path project, 
Path testDir) {
             this.schema = schema;
             this.tree = tree;
             this.root = YamlPointerLines.root(content);
+            this.lines = content.split("\n", -1);
+            this.project = project;
+            this.testDir = testDir;
+        }
+
+        /**
+         * A file as Citrus reads it, relative to the test: a file of the 
project named as the tools name it
+         * (sample-order.json for the file beside the test directory) is 
../sample-order.json from test/.
+         */
+        private String fromTest(String file) {
+            if (testDir == null || file.isBlank() || 
Files.exists(testDir.resolve(file))) {
+                return file;
+            }
+            Path inProject = project.resolve(file).normalize();
+            return Files.exists(inProject) ? 
testDir.relativize(inProject).toString().replace('\\', '/') : file;
         }
 
         List<String> problems() {
@@ -437,6 +494,7 @@ public final class CitrusTestChecks {
             }
             String name = scalar(keys.get(0).getKeyNode());
             items.put(at, name);
+            itemNodes.put(at, item);
             JsonNode action = tree.at(at);
             if (nested && !action.isMissingNode()) {
                 describe(schema.action().validate(action), at);
@@ -519,7 +577,8 @@ public final class CitrusTestChecks {
                     return message + ": a Citrus test is a map with the keys " 
+ String.join(", ", keys);
                 }
                 if (!keys.isEmpty()) {
-                    return what + ": " + message + "; " + name(what) + " takes 
" + String.join(", ", keys);
+                    String answer = what + ": " + message + "; " + name(what) 
+ " takes " + String.join(", ", keys);
+                    return isBody(keys) && tree.at(at).isValueNode() ? answer 
+ bodyText(at, tree.at(at).asText()) : answer;
                 }
             }
             if ("enum".equals(e.getKeyword()) && e.getSchemaNode() != null && 
e.getInstanceNode() != null) {
@@ -557,11 +616,241 @@ public final class CitrusTestChecks {
             }
             String what = where(at);
             // the keys of the action itself (http: {uri: ...}), or of a node 
in it (http.sendRequest)
-            String of = what.isEmpty() ? "a Citrus test"
-                    : items.containsKey(at.substring(0, Math.max(0, 
at.lastIndexOf('/')))) ? "the " + what + " action"
-                    : what;
-            return and(named) + (keys.size() == 1 ? " is not a key of " : " 
are not keys of ") + of
-                   + (takes.isEmpty() ? "" : ", which takes " + String.join(", 
", takes));
+            boolean action = items.containsKey(parent(at));
+            String of = what.isEmpty() ? "a Citrus test" : action ? "the " + 
what + " action" : what;
+            String answer = and(named) + (keys.size() == 1 ? " is not a key of 
" : " are not keys of ") + of
+                            + (takes.isEmpty() ? "" : ", which takes " + 
String.join(", ", takes));
+            if (action && "http".equals(what)) {
+                return answer + requestAndResponse(at, keys);
+            }
+            if (isBody(takes)) {
+                return answer + bodyKeys(at, at, keys);
+            }
+            if (takes.contains("body") && takes.contains("status")) {
+                // a check of the body as a key of the response: bodyContains 
(r26 of the local-model benchmark)
+                List<String> body = keys.stream().filter(k -> 
k.toLowerCase(Locale.ROOT).contains("body")).toList();
+                return body.isEmpty() ? answer : answer + bodyKeys(at, at + 
"/body", body);
+            }
+            return answer;
+        }
+
+        /**
+         * The request and its expected response written straight under http: 
(uri, method, requestFile, contentType and
+         * expectStatus in every write of test-citrus-orders step 1 of the 
local-model benchmark), or as request: and
+         * response: maps. An http action of a client sends the request or 
receives the response, so they are two
+         * actions: the lines to write with the values as written, when they 
pass the schema; else the shape of the two.
+         */
+        private String requestAndResponse(String at, List<String> keys) {
+            JsonNode http = 
schema.model().at("/definitions/TestActions/properties/http");
+            if (!hasPath(http, "sendRequest") || !hasPath(http, 
"receiveResponse", "response")
+                    || keys.stream().noneMatch(k -> isCallKey(k, http))) {
+                return "";
+            }
+            String shape = ". An http action of a client either sends the 
request (sendRequest) or receives the response"
+                           + " (receiveResponse)";
+            HttpCall call = new HttpCall(http);
+            tree.at(at).properties().forEach(e -> call.add(e.getKey(), 
e.getValue(), null));
+            List<JsonNode> actions = call.actions();
+            String written = actions != null ? itemLines(parent(at), actions) 
: null;
+            String left = call.left.isEmpty()
+                    ? ""
+                    : " (" + and(call.left) + (call.left.size() == 1 ? " has" 
: " have") + " no place in them and "
+                      + (call.left.size() == 1 ? "is" : "are") + " left out)";
+            if (written != null && actions.size() == 2) {
+                return shape + ", so the request and the response it expects 
are two http actions" + left + "." + written;
+            }
+            if (written != null) {
+                return shape + ", so the request is an action of its own, and 
a second http action checks the response"
+                       + " with receiveResponse: {response: {status: 
\"200\"}}" + left + "." + written;
+            }
+            return shape + ", so the request and the response it expects are 
two http actions, such as - http: {client:"
+                   + " \"http://localhost:8080\";, sendRequest: {POST: {path: 
\"/...\", body: {data: \"...\"}}}} and"
+                   + " - http: {client: \"http://localhost:8080\";, 
receiveResponse: {response: {status: \"200\"}}}";
+        }
+
+        /**
+         * A body written with a key of its own, text, equal, contains or 
equalTo (five of seven runs of
+         * test-citrus-orders step 2 had the whole case right but this key): 
the text goes under data:, as the body to
+         * send or the body the test expects; a part of the expected text is 
checked with jsonPath.
+         */
+        private String bodyKeys(String at, String bodyAt, List<String> keys) {
+            JsonNode node = tree.at(at);
+            String key = keys.stream().filter(k -> node.path(k).isValueNode() 
&& !node.path(k).isNull()).findFirst()
+                    .orElse(null);
+            if (key == null) {
+                return "";
+            }
+            String text = node.get(key).asText();
+            boolean contains = CONTAINS_KEYS.contains(key) || 
key.toLowerCase(Locale.ROOT).contains("contain");
+            if (contains && isReceived(bodyAt)) {
+                String jsonPath = jsonPathCheck(bodyAt, Map.of("<field>", 
text));
+                return "; data is the whole body the test expects, compared as 
a whole"
+                       + (jsonPath != null
+                               ? ". To check that one field holds this text, 
leave out " + (at.equals(bodyAt) ? "body" : key)
+                                 + ": and validate the field next to response: 
" + jsonPath
+                               : "");
+            }
+            if (!at.equals(bodyAt)) {
+                return "; the body the test expects goes under body: {data: " 
+ quoted(text) + "}";
+            }
+            return bodyText(at, text);
+        }
+
+        /**
+         * The lines of a body written as text, under data:, and what data is; 
for an expected JSON body, jsonPath too.
+         */
+        private String bodyText(String at, String text) {
+            boolean expected = isReceived(at);
+            Map<String, String> fields = expected ? jsonFields(text) : 
Map.of();
+            String jsonPath = fields.isEmpty() ? null : jsonPathCheck(at, 
fields);
+            String written = keyLines(at, JSON.createObjectNode().put("data", 
text));
+            return "; the text goes under data:, which is the body "
+                   + (expected ? "the test expects (Citrus compares a JSON 
body as JSON)" : "to send")
+                   + (jsonPath != null
+                           ? ". To check fields instead of the whole body, 
validate them next to response: " + jsonPath
+                           : "")
+                   + "." + (written != null ? written : " Write body: {data: " 
+ quoted(text) + "}");
+        }
+
+        /**
+         * The jsonPath check of the fields, as validate: [...] in one line, 
when the body is the one of the response of
+         * an http action, whose receiveResponse takes it; null otherwise.
+         */
+        private String jsonPathCheck(String at, Map<String, String> fields) {
+            JsonNode http = 
schema.model().at("/definitions/TestActions/properties/http");
+            if (!at.endsWith("/receiveResponse/response/body")
+                    || !hasPath(http, "receiveResponse", "validate", 
"jsonPath", "expression")
+                    || !hasPath(http, "receiveResponse", "validate", 
"jsonPath", "value")) {
+                return null;
+            }
+            StringJoiner checks = new StringJoiner(", ");
+            fields.forEach((field, value) -> checks.add("{expression: " + 
quoted("$." + field) + ", value: " + quoted(value)
+                                                        + "}"));
+            return "validate: [{jsonPath: [" + checks + "]}]";
+        }
+
+        /**
+         * " Write lines 14-19 as:" and the actions in place of the list item 
at the pointer, when they pass the schema;
+         * null when the item is not in block style.
+         */
+        private String itemLines(String pointer, List<JsonNode> actions) {
+            Node item = itemNodes.get(pointer);
+            int first = item != null ? line(item) - 1 : -1;
+            if (first < 0 || first >= lines.length) {
+                return null;
+            }
+            int dash = indent(lines[first]);
+            if (!lines[first].startsWith("- ", dash)) {
+                return null;
+            }
+            List<String> written = new ArrayList<>();
+            for (JsonNode a : actions) {
+                List<String> one = new ArrayList<>();
+                yaml(a, dash + 2, one);
+                one.set(0, " ".repeat(dash) + "- " + one.get(0).substring(dash 
+ 2));
+                written.addAll(one);
+            }
+            if (!validActions(written)) {
+                return null;
+            }
+            return writeLines(first + 1, blockEnd(first), written);
+        }
+
+        /** " Write lines 14-19 as:" or " Write line 43 as:", and the lines. */
+        private static String writeLines(int first, int last, List<String> 
written) {
+            return (first == last ? " Write line " + first : " Write lines " + 
first + "-" + last) + " as:\n"
+                   + String.join("\n", written);
+        }
+
+        /** " Write lines 52-53 as:" and the key at the pointer with the value 
given; null when it is not found. */
+        private String keyLines(String pointer, JsonNode value) {
+            Node key = keyNode(pointer);
+            int first = key != null ? line(key) - 1 : -1;
+            if (first < 0 || first >= lines.length) {
+                return null;
+            }
+            int column = indent(lines[first]);
+            if (!lines[first].startsWith(scalar(key) + ":", column)) {
+                return null;
+            }
+            List<String> written = new ArrayList<>();
+            yaml(JSON.createObjectNode().set(scalar(key), value), column, 
written);
+            return writeLines(first + 1, blockEnd(first), written);
+        }
+
+        /** Whether the lines are YAML of actions that the schema takes, each. 
*/
+        private boolean validActions(List<String> written) {
+            try {
+                JsonNode list = YAML.readTree(String.join("\n", written));
+                if (list == null || !list.isArray() || list.isEmpty()) {
+                    return false;
+                }
+                for (JsonNode a : list) {
+                    List<Error> errors = new ArrayList<>(
+                            YamlValidator.filterOneOfNoise(
+                                    new 
ArrayList<>(schema.action().validate(a))));
+                    errors.removeIf(CitrusTestChecks::convertedScalar);
+                    if (!errors.isEmpty()) {
+                        return false;
+                    }
+                }
+                return true;
+            } catch (IOException | RuntimeException e) {
+                return false;
+            }
+        }
+
+        /**
+         * The 1-based last line of the block of a line: the lines below it 
indented more, without blank lines and
+         * comments at its end.
+         */
+        private int blockEnd(int first) {
+            int column = indent(lines[first]);
+            int end = first + 1;
+            int last = first + 1;
+            while (end < lines.length) {
+                String l = lines[end];
+                if (!l.isBlank() && indent(l) <= column) {
+                    break;
+                }
+                end++;
+                if (!l.isBlank() && !l.strip().startsWith("#")) {
+                    last = end;
+                }
+            }
+            return last;
+        }
+
+        /** The YAML node of the key of the last segment of a pointer, or 
null. */
+        private Node keyNode(String pointer) {
+            Node node = root;
+            Node key = null;
+            for (String segment : pointer.split("/")) {
+                if (segment.isEmpty()) {
+                    continue;
+                }
+                String name = segment.replace("~1", "/").replace("~0", "~");
+                key = null;
+                if (node instanceof SequenceNode seq && 
INTEGER.matcher(name).matches()) {
+                    int index = Integer.parseInt(name);
+                    node = index < seq.getValue().size() ? 
seq.getValue().get(index) : null;
+                } else if (node instanceof MappingNode map) {
+                    Node value = null;
+                    for (NodeTuple t : map.getValue()) {
+                        if (name.equals(scalar(t.getKeyNode()))) {
+                            key = t.getKeyNode();
+                            value = t.getValueNode();
+                        }
+                    }
+                    node = value;
+                } else {
+                    return null;
+                }
+                if (node == null) {
+                    return null;
+                }
+            }
+            return key;
         }
 
         /**
@@ -578,6 +867,227 @@ public final class CitrusTestChecks {
             return answer;
         }
 
+        /** The keys a node of the schema takes: its properties and those of 
its references and branches. */
+        private Set<String> keysOf(JsonNode node) {
+            Set<String> answer = new LinkedHashSet<>();
+            collectKeys(node, answer, 0);
+            return answer;
+        }
+
+        /**
+         * The schema of a property of a node of the schema, through its 
references, branches and the items of a list
+         * (the methods of sendRequest are declared {} and have their schema 
in a branch); missing when there is none.
+         */
+        private JsonNode property(JsonNode node, String name) {
+            return property(node, name, 0);
+        }
+
+        private JsonNode property(JsonNode node, String name, int depth) {
+            if (node == null || node.isMissingNode() || depth > 6) {
+                return MissingNode.getInstance();
+            }
+            JsonNode p = node.path("properties").path(name);
+            if (!p.isMissingNode() && !p.isEmpty()) {
+                return p;
+            }
+            List<JsonNode> more = new ArrayList<>();
+            JsonNode ref = node.get("$ref");
+            if (ref != null && ref.isTextual() && 
ref.asText().startsWith("#")) {
+                more.add(schema.model().at(ref.asText().substring(1)));
+            }
+            for (String composition : new String[] { "allOf", "anyOf", "oneOf" 
}) {
+                node.path(composition).forEach(more::add);
+            }
+            if (node.has("items")) {
+                more.add(node.get("items"));
+            }
+            for (JsonNode n : more) {
+                JsonNode r = property(n, name, depth + 1);
+                if (!r.isMissingNode()) {
+                    return r;
+                }
+            }
+            return p;
+        }
+
+        /** Whether the schema has the properties one under the other from the 
node: receiveResponse, response. */
+        private boolean hasPath(JsonNode node, String... names) {
+            JsonNode n = node;
+            for (String name : names) {
+                n = property(n, name);
+                if (n.isMissingNode()) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        /** Whether a key the http action does not take is a part of a request 
or its response. */
+        private boolean isCallKey(String key, JsonNode http) {
+            return URL_KEYS.contains(key) || METHOD_KEYS.contains(key) || 
FILE_KEYS.contains(key)
+                    || BODY_KEYS.contains(key) || STATUS_KEYS.contains(key) || 
RESPONSE_BODY_KEYS.contains(key)
+                    || "request".equals(key) || "response".equals(key) || new 
HttpCall(http).isRequestOrResponseKey(key);
+        }
+
+        /**
+         * A call written as one http action, put in the two actions of a 
client: the request that sendRequest sends,
+         * with its method, and the response that receiveResponse expects. The 
keys of the request and of the response
+         * are the ones of the schema; the names models write for them are 
mapped.
+         */
+        private final class HttpCall {
+
+            private final Set<String> methods = new LinkedHashSet<>();
+            private final Set<String> requestKeys;
+            private final Set<String> responseKeys;
+            private final ObjectNode request = JSON.createObjectNode();
+            private final ObjectNode response = JSON.createObjectNode();
+            /** The keys as written that have no place in the two actions. */
+            private final List<String> left = new ArrayList<>();
+            private String client;
+            private String method;
+            private String description;
+
+            HttpCall(JsonNode http) {
+                JsonNode send = property(http, "sendRequest");
+                // the methods are the keys of sendRequest in upper case: GET, 
POST...
+                keysOf(send).stream().filter(k -> 
k.equals(k.toUpperCase(Locale.ROOT))).forEach(methods::add);
+                requestKeys = methods.isEmpty() ? Set.of() : 
plainKeys(property(send, methods.iterator().next()));
+                responseKeys = plainKeys(property(property(http, 
"receiveResponse"), "response"));
+            }
+
+            /**
+             * The keys of a request or a response that are not of a group 
(advanced, schema...): a key of a model's own
+             * with the name of one of those, such as type: Async, is not 
meant as it.
+             */
+            private Set<String> plainKeys(JsonNode node) {
+                Set<String> answer = new LinkedHashSet<>();
+                keysOf(node).stream().filter(k -> !property(node, 
k).has("$comment")).forEach(answer::add);
+                return answer;
+            }
+
+            boolean isRequestOrResponseKey(String key) {
+                return requestKeys.contains(key) || responseKeys.contains(key);
+            }
+
+            /**
+             * Puts a key as written in the request or the response, or among 
the keys left out: side is request or
+             * response for the keys of a request: or response: map, null for 
a key of the action.
+             */
+            void add(String key, JsonNode value, String side) {
+                if (!place(key, value, side)) {
+                    left.add(key);
+                }
+            }
+
+            private boolean place(String key, JsonNode value, String side) {
+                boolean forRequest = !"response".equals(side);
+                if (side == null && "client".equals(key) && value.isTextual()) 
{
+                    client = value.asText().strip();
+                } else if (side == null && "description".equals(key) && 
value.isTextual()) {
+                    description = value.asText();
+                } else if (side == null && ("request".equals(key) || 
"response".equals(key)) && value.isObject()) {
+                    value.properties().forEach(e -> add(e.getKey(), 
e.getValue(), key));
+                } else if (side == null && "sendRequest".equals(key) && 
value.isObject() && value.size() == 1
+                        && methods.contains(value.fieldNames().next()) && 
value.elements().next().isObject()) {
+                    method = value.fieldNames().next();
+                    value.get(method).properties().forEach(e -> 
request.set(e.getKey(), e.getValue()));
+                } else if (side == null && "receiveResponse".equals(key) && 
value.path("response").isObject()
+                        && value.size() == 1) {
+                    value.get("response").properties().forEach(e -> 
response.set(e.getKey(), e.getValue()));
+                } else if (forRequest && URL_KEYS.contains(key) && 
value.isTextual()) {
+                    Matcher m = URL.matcher(value.asText().strip());
+                    if (m.matches()) {
+                        client = m.group(1);
+                        if (!m.group(2).isEmpty()) {
+                            request.put("path", m.group(2));
+                        }
+                    } else {
+                        request.put("path", value.asText().strip());
+                    }
+                } else if (forRequest && METHOD_KEYS.contains(key) && 
value.isTextual()) {
+                    method = value.asText().strip().toUpperCase(Locale.ROOT);
+                } else if (forRequest && FILE_KEYS.contains(key) && 
value.isTextual()) {
+                    request.set("body", JSON.createObjectNode().set("resource",
+                            JSON.createObjectNode().put("file", 
fromTest(value.asText().strip()))));
+                } else if (!"request".equals(side) && 
STATUS_KEYS.contains(key) && value.isValueNode()) {
+                    response.put("status", value.asText());
+                } else if (side == null && RESPONSE_BODY_KEYS.contains(key) && 
value.isTextual()) {
+                    response.set("body", JSON.createObjectNode().put("data", 
value.asText()));
+                } else if ("body".equals(key) || forRequest && 
BODY_KEYS.contains(key)) {
+                    JsonNode body = body(value);
+                    if (body == null) {
+                        return false;
+                    }
+                    (forRequest ? request : response).set("body", body);
+                } else if (value.isValueNode() && !value.isNull()
+                        && (forRequest ? requestKeys : 
responseKeys).contains(key)) {
+                    // contentType and path of the request, contentType of the 
response
+                    (forRequest ? request : response).put(key, value.asText());
+                } else {
+                    return false;
+                }
+                return true;
+            }
+
+            /** The body of a request or a response: text as data, or a map of 
one of data, resource or script. */
+            private static JsonNode body(JsonNode value) {
+                if (value.isValueNode() && !value.isNull()) {
+                    return JSON.createObjectNode().put("data", value.asText());
+                }
+                return value.isObject() && value.size() == 1
+                        && (value.has("data") || value.has("resource") || 
value.has("script")) ? value : null;
+            }
+
+            /**
+             * The action that sends the request, and the one that receives 
the response when a part of it is given;
+             * null without a client, or with a method the schema does not 
have.
+             */
+            List<JsonNode> actions() {
+                String m = method != null ? method : request.has("body") ? 
"POST" : "GET";
+                if (client == null || client.isBlank() || 
!methods.contains(m)) {
+                    return null;
+                }
+                List<JsonNode> answer = new ArrayList<>();
+                ObjectNode send = JSON.createObjectNode();
+                send.set(m, ordered(request, "path"));
+                answer.add(http("sendRequest", send));
+                if (description != null) {
+                    ((ObjectNode) 
answer.get(0).get("http")).put("description", description);
+                }
+                if (!response.isEmpty()) {
+                    ObjectNode receive = JSON.createObjectNode();
+                    receive.set("response", ordered(response, "status"));
+                    answer.add(http("receiveResponse", receive));
+                }
+                return answer;
+            }
+
+            private ObjectNode http(String key, ObjectNode value) {
+                ObjectNode http = JSON.createObjectNode().put("client", 
client);
+                http.set(key, value);
+                ObjectNode action = JSON.createObjectNode();
+                action.set("http", http);
+                return action;
+            }
+
+            /** The keys with the given one first and the body last, as the 
tests of the examples write them. */
+            private static ObjectNode ordered(ObjectNode node, String first) {
+                ObjectNode answer = JSON.createObjectNode();
+                if (node.has(first)) {
+                    answer.set(first, node.get(first));
+                }
+                node.properties().forEach(e -> {
+                    if (!first.equals(e.getKey()) && 
!"body".equals(e.getKey())) {
+                        answer.set(e.getKey(), e.getValue());
+                    }
+                });
+                if (node.has("body")) {
+                    answer.set("body", node.get("body"));
+                }
+                return answer;
+            }
+        }
+
         private void collectKeys(JsonNode node, Set<String> answer, int depth) 
{
             if (node == null || node.isMissingNode() || depth > 4) {
                 return;
@@ -626,6 +1136,166 @@ public final class CitrusTestChecks {
         return what.isEmpty() ? "a Citrus test" : 
what.substring(what.lastIndexOf('.') + 1);
     }
 
+    /** Whether the keys an object takes are the ones of a message body: data, 
resource and script. */
+    private static boolean isBody(Set<String> keys) {
+        return keys.contains("data") && keys.contains("resource");
+    }
+
+    /** Whether the node at the pointer is in a message the test receives, 
such as the response of an http request. */
+    private static boolean isReceived(String pointer) {
+        return List.of(pointer.split("/")).stream()
+                .anyMatch(s -> "receiveResponse".equals(s) || 
"receiveRequest".equals(s) || "receive".equals(s));
+    }
+
+    /** The pointer of the node that holds the node at the pointer. */
+    private static String parent(String pointer) {
+        return pointer.substring(0, Math.max(0, pointer.lastIndexOf('/')));
+    }
+
+    /** The fields of a JSON object with a text, number or boolean value, by 
name; empty for any other text. */
+    private static Map<String, String> jsonFields(String text) {
+        Map<String, String> answer = new LinkedHashMap<>();
+        try {
+            JsonNode json = text != null && text.strip().startsWith("{") ? 
JSON.readTree(text) : null;
+            if (json != null && json.isObject()) {
+                json.properties().forEach(e -> {
+                    if (e.getValue().isValueNode() && !e.getValue().isNull() 
&& e.getKey().matches("[A-Za-z_]\\w*")) {
+                        answer.put(e.getKey(), e.getValue().asText());
+                    }
+                });
+            }
+        } catch (IOException e) {
+            // not JSON: no fields
+        }
+        return answer;
+    }
+
+    /**
+     * What is said of a test that calls an integration (an http action, or a 
check of what an integration logged) but
+     * does not start one: camel test run runs only the test. The action that 
starts it, with the route file of the
+     * project when there is one, and the name the checks of the test give the 
integration (no test a local model wrote
+     * for test-citrus-orders step 1 started the integration). Empty when the 
test starts one or calls none.
+     *
+     * @param content the test
+     * @param dir     the project directory, or null
+     * @param file    the test file, relative to the directory
+     */
+    static String startNote(String content, Path dir, String file) {
+        TestSchema s = schema();
+        JsonNode tree;
+        try {
+            tree = s.model() != null && content != null && !content.isBlank() 
? YAML.readTree(content) : null;
+        } catch (IOException e) {
+            tree = null;
+        }
+        if (tree == null || !tree.isObject()) {
+            return "";
+        }
+        String cli = 
s.model().at("/definitions/TestActions/properties/camel/properties/cli").isMissingNode()
+                ? "jbang" : "cli";
+        List<JsonNode> camel = tree.findValues("camel");
+        if (camel.stream().anyMatch(c -> c.path("cli").has("run") || 
c.path("jbang").has("run"))) {
+            return "";
+        }
+        String name = camel.stream().map(c -> 
c.path(cli).path("verify").path("integration"))
+                
.filter(JsonNode::isTextual).map(JsonNode::asText).findFirst().orElse(null);
+        if (name == null && tree.findValues("http").isEmpty()) {
+            return "";
+        }
+        // the route file of the project: the one *.camel.yaml in the 
directory, read from the directory of the test
+        Path project = dir != null ? dir.toAbsolutePath().normalize() : null;
+        Path testDir = project != null && file != null ? 
project.resolve(file).normalize().getParent() : null;
+        List<Path> routes = new ArrayList<>();
+        if (testDir != null && Files.isDirectory(project)) {
+            try (Stream<Path> files = Files.list(project)) {
+                files.filter(p -> 
p.getFileName().toString().matches(".+\\.camel\\.ya?ml")).sorted().forEach(routes::add);
+            } catch (IOException e) {
+                routes.clear();
+            }
+        }
+        String start = "The test calls the integration but does not start it, 
and camel test run runs only the test:"
+                       + " start it with the first action of the test (or have 
it running when the test runs)";
+        if (routes.size() != 1) {
+            String example = name != null ? name : "my-app";
+            return start + ", such as - camel: {" + cli + ": {run: 
{integration: {name: " + example + ", file: ../"
+                   + example + ".camel.yaml}}}} for the route file " + example 
+ ".camel.yaml in the directory above the"
+                   + " test (a file is relative to the test)";
+        }
+        String route = routes.get(0).getFileName().toString();
+        ObjectNode integration = JSON.createObjectNode();
+        integration.put("name", name != null ? name : route.substring(0, 
route.indexOf(".camel.")));
+        integration.put("file", 
testDir.relativize(routes.get(0)).toString().replace('\\', '/'));
+        if (Files.isRegularFile(project.resolve("application.properties"))) {
+            integration.set("systemProperties", 
JSON.createObjectNode().put("file",
+                    
testDir.relativize(project.resolve("application.properties")).toString().replace('\\',
 '/')));
+        }
+        ObjectNode run = JSON.createObjectNode();
+        run.set("integration", integration);
+        ObjectNode action = JSON.createObjectNode();
+        action.set("camel", JSON.createObjectNode().set(cli, 
JSON.createObjectNode().set("run", run)));
+        List<Error> errors = new 
ArrayList<>(YamlValidator.filterOneOfNoise(new 
ArrayList<>(s.action().validate(action))));
+        if (!errors.isEmpty()) {
+            return "";
+        }
+        List<String> lines = new ArrayList<>();
+        yaml(action, 4, lines);
+        lines.set(0, "  - " + lines.get(0).substring(4));
+        return start + ":\n" + String.join("\n", lines);
+    }
+
+    /**
+     * A node of the schema's shape as YAML lines in block style at the 
indentation given: a text value quoted, a text
+     * of several lines as a block, an empty map as {}.
+     */
+    static void yaml(JsonNode node, int indent, List<String> out) {
+        String pad = " ".repeat(indent);
+        node.properties().forEach(e -> {
+            JsonNode v = e.getValue();
+            if (v.isObject() && !v.isEmpty()) {
+                out.add(pad + e.getKey() + ":");
+                yaml(v, indent + 2, out);
+            } else if (v.isObject()) {
+                out.add(pad + e.getKey() + ": {}");
+            } else if (v.isArray()) {
+                out.add(pad + e.getKey() + ":");
+                for (JsonNode item : v) {
+                    List<String> one = new ArrayList<>();
+                    yaml(item, indent + 4, one);
+                    if (!one.isEmpty()) {
+                        one.set(0, pad + "  - " + one.get(0).substring(indent 
+ 4));
+                        out.addAll(one);
+                    }
+                }
+            } else if (v.asText().contains("\n")) {
+                String text = v.asText();
+                boolean newline = text.endsWith("\n");
+                out.add(pad + e.getKey() + ": " + (newline ? "|" : "|-"));
+                for (String l : (newline ? text.substring(0, text.length() - 
1) : text).split("\n", -1)) {
+                    out.add(l.isEmpty() ? "" : pad + "  " + l);
+                }
+            } else {
+                out.add(pad + e.getKey() + ": " + quoted(v.asText()));
+            }
+        });
+    }
+
+    /**
+     * A text as a YAML value in quotes: double quotes, or single quotes when 
it holds a double quote or a backslash.
+     */
+    static String quoted(String text) {
+        return text.contains("\"") || text.contains("\\")
+                ? "'" + text.replace("'", "''") + "'"
+                : "\"" + text + "\"";
+    }
+
+    private static int indent(String line) {
+        int i = 0;
+        while (i < line.length() && line.charAt(i) == ' ') {
+            i++;
+        }
+        return i;
+    }
+
     private static boolean isComposition(Error e) {
         return "oneOf".equals(e.getKeyword()) || 
"anyOf".equals(e.getKeyword());
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index 135c7ccf35f1..abe5b489c80e 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -144,7 +144,7 @@ public final class SourceValidator {
             // Checked for its YAML syntax only, a test with actions Citrus 
does not have was written, and camel test
             // run then failed on it: so the test schema of Citrus checks it 
too
             List<String> syntax = validateYamlSyntax(content);
-            return syntax.isEmpty() ? CitrusTestChecks.validate(content) : 
syntax;
+            return syntax.isEmpty() ? CitrusTestChecks.validate(content, 
directory, fileName) : syntax;
         }
         if (name.endsWith(".yaml") || name.endsWith(".yml")) {
             // a rest binding to an OpenAPI specification hides the verb, and 
the specification is a file beside the
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsCitrusTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsCitrusTest.java
index 572fa481d812..486b8647e2c2 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsCitrusTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsCitrusTest.java
@@ -179,6 +179,8 @@ class AuthoringToolsCitrusTest {
                 """;
         Files.createDirectories(dir.resolve("test"));
         Files.writeString(dir.resolve(FILE), SAMPLE);
+        Files.writeString(dir.resolve("orders.camel.yaml"), "- from:\n    uri: 
timer:tick\n    steps:\n      - log: hi\n");
+        Files.writeString(dir.resolve("application.properties"), 
"camel.server.port=8080\n");
 
         JsonObject result = AuthoringTools.writeFile(new ToolContext(), dir, 
FILE, invented, true);
 
@@ -187,8 +189,22 @@ class AuthoringToolsCitrusTest {
                 .contains("Line 4: uri, method, requestFile and expectStatus 
are not keys of the http action, which"
                           + " takes actor, client,")
                 .contains("Line 8: assert-log is not a Citrus test action: 
echo: {message: ");
+        // the test starts no integration: the action that starts the one of 
the project, not a tool the client may not
+        // have (camel_catalog_examples is not a core tool)
         assertThat(result.getString("message")).startsWith("The file was not 
written")
-                .contains("camel_catalog_examples with filter citrus");
+                .doesNotContain("camel_catalog_examples")
+                .endsWith(" The test calls the integration but does not start 
it, and camel test run runs only the"
+                          + " test: start it with the first action of the test 
(or have it running when the test"
+                          + " runs):\n" + """
+                                    - camel:
+                                        cli:
+                                          run:
+                                            integration:
+                                              name: "orders"
+                                              file: "../orders.camel.yaml"
+                                              systemProperties:
+                                                file: 
"../application.properties"
+                                  """.stripTrailing());
         assertThat(result.get("repaired")).isNull();
         assertThat(Files.readString(dir.resolve(FILE))).isEqualTo(SAMPLE);
 
@@ -198,6 +214,36 @@ class AuthoringToolsCitrusTest {
         assertThat(Files.readString(dir.resolve(FILE))).isEqualTo(ORDERS_TEST);
     }
 
+    @Test
+    void 
aTestThatCallsTheIntegrationWithoutStartingItIsWrittenWithTheActionThatStartsIt(@TempDir
 Path dir)
+            throws IOException {
+        // camel test run runs only the test: with no action that starts the 
integration, the call fails (the
+        // integration a test of test-citrus-orders calls is not running while 
the test runs). The name is the one the
+        // test checks the log of
+        Files.writeString(dir.resolve("orders.camel.yaml"), "- from:\n    uri: 
timer:tick\n    steps:\n      - log: hi\n");
+        String test = ORDERS_TEST.substring(0, ORDERS_TEST.indexOf("  - 
camel:\n      cli:\n        run:"))
+                      + ORDERS_TEST.substring(ORDERS_TEST.indexOf("  - 
http:")).replace("\"orders\"", "\"orders-app\"");
+
+        JsonObject result = AuthoringTools.writeFile(new ToolContext(), dir, 
FILE, test, true);
+
+        
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("created");
+        
assertThat(result.getString("message")).isEqualTo(AuthoringTools.citrusTestNote(FILE)
+                                                          + " The test calls 
the integration but does not start it, and"
+                                                          + " camel test run 
runs only the test: start it with the first"
+                                                          + " action of the 
test (or have it running when the test"
+                                                          + " runs):\n" + """
+                                                                    - camel:
+                                                                        cli:
+                                                                          run:
+                                                                            
integration:
+                                                                              
name: "orders-app"
+                                                                              
file: "../orders.camel.yaml"
+                                                                  
""".stripTrailing());
+        // with the action that starts it, nothing is said
+        assertThat(AuthoringTools.writeFile(new ToolContext(), dir, FILE, 
ORDERS_TEST, true).getString("message"))
+                .isEqualTo(AuthoringTools.citrusTestNote(FILE));
+    }
+
     @Test
     void anEditThatBreaksTheTestIsNotWritten(@TempDir Path dir) throws 
IOException {
         // step 2: the body of the request written as text, where Citrus takes 
body: {data: ...}
@@ -295,8 +341,7 @@ class AuthoringToolsCitrusTest {
                 """);
         assertThat(log.getBoolean("valid")).isFalse();
         assertThat(log.get("errors").toString()).contains("Line 3: log is not 
a Citrus test action");
-        assertThat(log.getString("message")).isEqualTo("1 problem(s) found; 
fix them before writing the file. "
-                                                       + 
AuthoringTools.CITRUS_SAMPLES);
+        assertThat(log.getString("message")).isEqualTo("1 problem(s) found; 
fix them before writing the file");
 
         // a quote that is not closed: the parser reads to the end of the 
file, and names the line of the quote
         JsonObject invalid = AuthoringTools.validate(new ToolContext(), FILE, 
"""
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecksTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecksTest.java
index 95c0c2456919..a8932a6584eb 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecksTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecksTest.java
@@ -19,12 +19,16 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
 import java.net.URL;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Stream;
 
 import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.junit.jupiter.api.Named;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
@@ -46,6 +50,11 @@ class CitrusTestChecksTest {
                                            + " {verify: {integration: 
\"<name>\", logMessage: \"...\"}}} checks that the"
                                            + " integration logged a line";
 
+    /** What is said of the request and its response written as one http 
action, before the lines to write. */
+    private static final String TWO_ACTIONS = ". An http action of a client 
either sends the request (sendRequest) or"
+                                              + " receives the response 
(receiveResponse), so the request and the"
+                                              + " response it expects are two 
http actions.";
+
     @Test
     void theSchemaIsOnTheClasspath() {
         
assertThat(CitrusTestChecks.hasSchema()).as(CitrusTestChecks.schemaProblem()).isTrue();
@@ -83,6 +92,10 @@ class CitrusTestChecksTest {
     @MethodSource("bundledTests")
     void theTestsOfTheExamplesAreValid(Path file) throws Exception {
         assertThat(validate(file)).isEmpty();
+        // each starts the integration it calls
+        Path project = file.getParent().getParent();
+        assertThat(CitrusTestChecks.startNote(Files.readString(file), project, 
project.relativize(file).toString()))
+                .isEmpty();
     }
 
     @Test
@@ -92,7 +105,23 @@ class CitrusTestChecksTest {
         assertThat(msgs).hasSize(3);
         assertThat(msgs.get(0)).isEqualTo("Line 32: uri, method, requestFile, 
contentType and expectStatus are not keys of"
                                           + " the http action, which takes 
actor, client, description, receiveRequest,"
-                                          + " receiveResponse, sendRequest, 
sendResponse, server");
+                                          + " receiveResponse, sendRequest, 
sendResponse, server" + TWO_ACTIONS
+                                          + " Write lines 31-36 as:\n" + """
+                                                    - http:
+                                                        client: 
"http://localhost:8080";
+                                                        sendRequest:
+                                                          POST:
+                                                            path: "/orders"
+                                                            contentType: 
"application/json"
+                                                            body:
+                                                              resource:
+                                                                file: 
"sample-order.json"
+                                                    - http:
+                                                        client: 
"http://localhost:8080";
+                                                        receiveResponse:
+                                                          response:
+                                                            status: "201"
+                                                  """.stripTrailing());
         assertThat(msgs.get(1)).isEqualTo("Line 39: log is not a Citrus test 
action: " + LOG_HINT);
         assertThat(msgs.get(2)).isEqualTo("Line 44: assert-log is not a Citrus 
test action: " + LOG_HINT);
     }
@@ -103,7 +132,25 @@ class CitrusTestChecksTest {
 
         assertThat(msgs).hasSize(2);
         assertThat(msgs.get(0)).startsWith("Line 30: request and response are 
not keys of the http action, which takes")
-                .contains("sendRequest", "receiveResponse");
+                .contains("sendRequest", "receiveResponse")
+                .contains(TWO_ACTIONS + " Write lines 29-50 as:\n  - http:\n")
+                // the text of the request and of the response as written, 
under data:
+                .contains("""
+                                  body:
+                                    data: |
+                                      {
+                                        "orderId": "ORD-1001",
+                        """)
+                .endsWith("""
+                                response:
+                                  status: "201"
+                                  body:
+                                    data: |
+                                      {
+                                        "orderId": "ORD-1001",
+                                        "status": "accepted"
+                                      }
+                        """.stripTrailing());
         assertThat(msgs.get(1)).startsWith("Line 53: log is not a Citrus test 
action");
     }
 
@@ -113,10 +160,253 @@ class CitrusTestChecksTest {
         List<String> msgs = 
validate(ORDERS.resolve("body-as-text.citrus.it.yaml"));
 
         assertThat(msgs).hasSize(2);
-        assertThat(msgs.get(0)).isEqualTo("Line 60: 
http.sendRequest.POST.body: string found, object expected; body takes"
-                                          + " data, resource, script");
-        assertThat(msgs.get(1)).isEqualTo("Line 66: 
http.receiveResponse.response.body: string found, object expected;"
-                                          + " body takes data, resource, 
script");
+        assertThat(msgs.get(0)).isEqualTo("""
+                Line 60: http.sendRequest.POST.body: string found, object 
expected; body takes data, resource, script; the\
+                 text goes under data:, which is the body to send. Write line 
60 as:
+                          body:
+                            data: '{"orderId": "ORD-1002", "country": "US", 
"lines": [{"sku": "CAMEL-HAT", "qty": 1,\
+                 "price": 15.0}]}'""");
+        assertThat(msgs.get(1)).isEqualTo("""
+                Line 66: http.receiveResponse.response.body: string found, 
object expected; body takes data, resource,\
+                 script; the text goes under data:, which is the body the test 
expects (Citrus compares a JSON body as\
+                 JSON). To check fields instead of the whole body, validate 
them next to response: validate: [{jsonPath:\
+                 [{expression: "$.error", value: "an order needs a 
customer"}]}]. Write line 66 as:
+                          body:
+                            data: '{"error": "an order needs a customer"}'""");
+    }
+
+    /**
+     * r28 of the local-model benchmark, test-citrus-orders step 1: all eight 
writes put the request and its status
+     * straight under http:, and were refused with the keys of http only. The 
lines to write are the two actions with
+     * the values as written, the file the request sends named from the test 
(Citrus reads it from there); written, the
+     * http problem is gone.
+     */
+    @Test
+    void theRequestAndItsStatusUnderHttpAreTwoActions(@TempDir Path dir) 
throws Exception {
+        Files.writeString(dir.resolve("sample-order.json"), "{\"orderId\": 
\"ORD-1001\"}");
+        Files.createDirectories(dir.resolve("test"));
+        String test = """
+                name: orders.citrus.it
+                actions:
+                  # POST the sample order to /orders endpoint
+                  - http:
+                      uri: http://localhost:8080/orders
+                      method: POST
+                      requestFile: sample-order.json
+                      contentType: application/json
+                      expectStatus: 201
+
+                  - camel:
+                      cli:
+                        verify:
+                          integration: orders
+                          logMessage: "Order ORD-1001 accepted for C-482 with 
2 line(s)"
+                """;
+        List<String> msgs = CitrusTestChecks.validate(test, dir, 
"test/orders.citrus.it.yaml");
+
+        assertThat(msgs).singleElement().asString()
+                .startsWith("Line 5: uri, method, requestFile, contentType and 
expectStatus are not keys of the http"
+                            + " action, which takes actor, client,")
+                .endsWith(TWO_ACTIONS + " Write lines 4-9 as:\n" + """
+                          - http:
+                              client: "http://localhost:8080";
+                              sendRequest:
+                                POST:
+                                  path: "/orders"
+                                  contentType: "application/json"
+                                  body:
+                                    resource:
+                                      file: "../sample-order.json"
+                          - http:
+                              client: "http://localhost:8080";
+                              receiveResponse:
+                                response:
+                                  status: "201"
+                        """.stripTrailing());
+        assertThat(CitrusTestChecks.validate(written(test, msgs.get(0)), dir, 
"test/orders.citrus.it.yaml")).isEmpty();
+    }
+
+    @Test
+    void theTextOfTheRequestUnderHttpGoesUnderData() {
+        // r28n-3 step 1: url, the order as text, and statusCode
+        String test = """
+                name: orders.citrus.it
+                actions:
+                  - http:
+                      url: http://localhost:8080/orders
+                      method: POST
+                      body: |
+                        {
+                          "orderId": "ORD-1001",
+                          "customer": "C-482"
+                        }
+                      statusCode: 201
+                """;
+        List<String> msgs = CitrusTestChecks.validate(test);
+
+        assertThat(msgs).singleElement().asString()
+                .startsWith("Line 4: url, method, body and statusCode are not 
keys of the http action")
+                .endsWith(TWO_ACTIONS + " Write lines 3-11 as:\n" + """
+                          - http:
+                              client: "http://localhost:8080";
+                              sendRequest:
+                                POST:
+                                  path: "/orders"
+                                  body:
+                                    data: |
+                                      {
+                                        "orderId": "ORD-1001",
+                                        "customer": "C-482"
+                                      }
+                          - http:
+                              client: "http://localhost:8080";
+                              receiveResponse:
+                                response:
+                                  status: "201"
+                        """.stripTrailing());
+        assertThat(CitrusTestChecks.validate(written(test, 
msgs.get(0)))).isEmpty();
+    }
+
+    @Test
+    void aRequestWithoutAStatusIsOneActionAndTheKeysWithNoPlaceAreSaid() {
+        // r26n-7 step 1 wrote the request without its status; r26n-2 had 
type: Async, which Citrus has no place for
+        String test = """
+                name: orders.citrus.it
+                actions:
+                  - http:
+                      uri: http://localhost:8080/orders
+                      type: Async
+                      method: POST
+                      contentType: application/json
+                      requestFile: sample-order.json
+                """;
+        List<String> msgs = CitrusTestChecks.validate(test);
+
+        assertThat(msgs).singleElement().asString()
+                .contains(", so the request is an action of its own, and a 
second http action checks the response with"
+                          + " receiveResponse: {response: {status: \"200\"}} 
(type has no place in them and is left out)."
+                          + " Write lines 3-8 as:\n  - http:\n")
+                .doesNotContain("Async", "receiveResponse:\n");
+        assertThat(CitrusTestChecks.validate(written(test, 
msgs.get(0)))).isEmpty();
+    }
+
+    @Test
+    void aRequestUnderHttpWithoutItsUrlIsSaidByItsShape() {
+        String test = """
+                name: t
+                actions:
+                  - http:
+                      method: GET
+                      expectStatus: 200
+                """;
+        assertThat(CitrusTestChecks.validate(test)).singleElement().asString()
+                .startsWith("Line 4: method and expectStatus are not keys of 
the http action")
+                .endsWith(", so the request and the response it expects are 
two http actions, such as - http: {client:"
+                          + " \"http://localhost:8080\";, sendRequest: {POST: 
{path: \"/...\", body: {data: \"...\"}}}}"
+                          + " and - http: {client: \"http://localhost:8080\";, 
receiveResponse: {response: {status:"
+                          + " \"200\"}}}");
+        // a key close to one of http: what is said of it does not change
+        assertThat(CitrusTestChecks.validate("""
+                name: t
+                actions:
+                  - http:
+                      clientt: c
+                """)).containsExactly("Line 4: clientt (did you mean client?) 
is not a key of the http action, which"
+                                      + " takes actor, client, description, 
receiveRequest, receiveResponse, sendRequest,"
+                                      + " sendResponse, server");
+    }
+
+    /**
+     * r28 test-citrus-orders step 2: five of seven runs had the whole case 
right except the check of the body, written
+     * as text, equal or equalTo; the body the test expects is data, and its 
fields can be checked with jsonPath.
+     */
+    @ParameterizedTest
+    @ValueSource(strings = { "text", "equal", "equalTo" })
+    void theBodyTheTestExpectsIsData(String key) {
+        String test = CASE_400.formatted(key + ": '{\"error\": \"an order 
needs a customer\"}'");
+        List<String> msgs = CitrusTestChecks.validate(test);
+
+        assertThat(msgs).singleElement().asString().isEqualTo("""
+                Line 16: %s is not a key of 
http.receiveResponse.response.body, which takes data, resource, script; the\
+                 text goes under data:, which is the body the test expects 
(Citrus compares a JSON body as JSON). To\
+                 check fields instead of the whole body, validate them next to 
response: validate: [{jsonPath:\
+                 [{expression: "$.error", value: "an order needs a 
customer"}]}]. Write lines 15-16 as:
+                          body:
+                            data: '{"error": "an order needs a 
customer"}'""".formatted(key));
+        assertThat(CitrusTestChecks.validate(written(test, 
msgs.get(0)))).isEmpty();
+        // the check the message offers instead
+        assertThat(CitrusTestChecks.validate(CASE_400.formatted("data: 
x").replace("""
+                          body:
+                            data: x
+                """, """
+                        validate: [{jsonPath: [{expression: "$.error", value: 
"an order needs a customer"}]}]
+                """))).isEmpty();
+    }
+
+    @Test
+    void aPartOfTheBodyTheTestExpectsIsCheckedWithJsonPath() {
+        // r28n-4 and r28n-7 step 2
+        assertThat(CitrusTestChecks.validate(CASE_400.formatted("contains: 
\"an order needs a customer\"")))
+                .containsExactly("Line 16: contains is not a key of 
http.receiveResponse.response.body, which takes"
+                                 + " data, resource, script; data is the whole 
body the test expects, compared as a"
+                                 + " whole. To check that one field holds this 
text, leave out body: and validate the"
+                                 + " field next to response: validate: 
[{jsonPath: [{expression: \"$.<field>\", value:"
+                                 + " \"an order needs a customer\"}]}]");
+    }
+
+    @Test
+    void theTextOfARequestGoesUnderData() {
+        // r28n-2 step 2: the body of the request as text
+        String test = """
+                name: t
+                actions:
+                  - http:
+                      client: "http://localhost:8080";
+                      sendRequest:
+                        POST:
+                          path: "/orders"
+                          body: '{"orderId": "ORD-1002", "country": "US"}'
+                """;
+        List<String> msgs = CitrusTestChecks.validate(test);
+
+        assertThat(msgs).singleElement().asString().isEqualTo("""
+                Line 8: http.sendRequest.POST.body: string found, object 
expected; body takes data, resource, script; the\
+                 text goes under data:, which is the body to send. Write line 
8 as:
+                          body:
+                            data: '{"orderId": "ORD-1002", "country": 
"US"}'""");
+        assertThat(CitrusTestChecks.validate(written(test, 
msgs.get(0)))).isEmpty();
+    }
+
+    /** The case of test-citrus-orders step 2, with the check of the body of 
the response to fill in. */
+    private static final String CASE_400 = """
+            name: t
+            actions:
+              - http:
+                  client: "http://localhost:8080";
+                  sendRequest:
+                    POST:
+                      path: "/orders"
+                      body:
+                        data: '{"orderId": "ORD-1002"}'
+              - http:
+                  client: "http://localhost:8080";
+                  receiveResponse:
+                    response:
+                      status: "400"
+                      body:
+                        %s
+            """;
+
+    /** The test with the lines a message says to write in place of the lines 
it names. */
+    private static String written(String test, String message) {
+        Matcher m = Pattern.compile(" Write lines? (\\d+)(?:-(\\d+))? 
as:\n").matcher(message);
+        assertThat(m.find()).as(message).isTrue();
+        int first = Integer.parseInt(m.group(1));
+        int last = m.group(2) != null ? Integer.parseInt(m.group(2)) : first;
+        List<String> lines = new ArrayList<>(List.of(test.split("\n", -1)));
+        lines.subList(first - 1, last).clear();
+        lines.addAll(first - 1, 
List.of(message.substring(m.end()).split("\n")));
+        return String.join("\n", lines);
     }
 
     @Test

Reply via email to