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 6a8963e32a06 CAMEL-26876: camel-jbang - AI tools: a Citrus test that
verifies a log line with regex escapes is refused with the line to write
(#29698)
6a8963e32a06 is described below
commit 6a8963e32a06b0baa7c5125d885d23f5101a55c6
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Nov 12 13:09:21 2026 +0100
CAMEL-26876: camel-jbang - AI tools: a Citrus test that verifies a log line
with regex escapes is refused with the line to write (#29698)
The logMessage of a camel cli (or jbang) verify action is plain text:
Citrus checks that the integration logged a line that contains it as
written. In r32 of the local-model benchmark a model wrote it as a regular
expression (line\(s\)), and camel test run waited 60 attempts for a line
with the backslashes in it. A backslash before ( ) [ ] { } . * + ? ^ $ |
now refuses the write, and the message ends with the line to write without
those backslashes. A backslash before another character is left alone.
The answer to a log or assert-log action, which points to the verify
action, now says that it matches the text as written (plain text, not a
pattern).
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../jbang/core/commands/ai/CitrusTestChecks.java | 52 ++++++++++++-
.../core/commands/ai/CitrusTestChecksTest.java | 85 +++++++++++++++++++++-
2 files changed, 134 insertions(+), 3 deletions(-)
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 0c435d48fb54..49583572526c 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
@@ -45,6 +45,7 @@ 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.databind.node.TextNode;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.networknt.schema.Error;
import com.networknt.schema.Schema;
@@ -105,6 +106,11 @@ public final class CitrusTestChecks {
"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+.-]*://[^/?#]+)(.*)$");
+ /**
+ * A backslash before a character of a regular expression, such as \( in
line\(s\), or before a second backslash:
+ * the two backslashes are taken as a pair, so the character after them is
not escaped.
+ */
+ private static final Pattern REGEX_ESCAPE =
Pattern.compile("\\\\([\\\\()\\[\\]{}.*+?^$|])");
private static volatile TestSchema schema;
@@ -526,6 +532,9 @@ public final class CitrusTestChecks {
String name = scalar(keys.get(0).getKeyNode());
items.put(at, name);
itemNodes.put(at, item);
+ if ("camel".equals(name)) {
+ checkLogMessage(at);
+ }
JsonNode action = tree.at(at);
if (nested && !action.isMissingNode()) {
describe(schema.action().validate(action), at);
@@ -548,16 +557,55 @@ public final class CitrusTestChecks {
}
if (camelCaseWords(name).contains("log") &&
schema.actions().contains("echo")
&& schema.actions().contains("camel")) {
- // log:, assert-log: or assertLog: in 8 of the 11 tests a
local model wrote (test-citrus-orders)
+ // log:, assert-log: or assertLog: in 8 of the 11 tests a
local model wrote (test-citrus-orders); a
+ // model that wrote assert-log: {pattern: ...} kept the
escapes of the pattern in logMessage (r32)
String cli =
schema.model().at("/definitions/TestActions/properties/camel/properties/cli").isMissingNode()
? "jbang" : "cli";
return answer + ": echo: {message: \"...\"} writes a message
to the test log, and camel: {" + cli
+ ": {verify: {integration: \"<name>\", logMessage:
\"...\"}}} checks that the integration"
- + " logged a line";
+ + " logged a line that contains that text as written
(plain text, not a pattern)";
}
return answer + "; the actions are " + String.join(", ",
schema.actions());
}
+ /**
+ * The logMessage of a camel cli (or jbang) verify action is plain
text: Citrus checks that the integration
+ * logged a line that contains it as written. In r32 of the
local-model benchmark, three of seven runs of
+ * test-citrus-orders step 1 wrote it as a regular expression,
line\(s\) for line(s), and camel test run waited
+ * 60 attempts for a line with the backslashes in it (the model had
first written assert-log: {pattern: ...},
+ * which is not a Citrus action, and kept the escapes). The line to
write has the text without the backslash of
+ * each escape. A backslash before another character, as in a Windows
path or an escaped quote, is left alone.
+ */
+ private void checkLogMessage(String at) {
+ JsonNode camel =
schema.model().at("/definitions/TestActions/properties/camel");
+ for (String cli : List.of("cli", "jbang")) {
+ String pointer = at + "/camel/" + cli + "/verify/logMessage";
+ JsonNode value = tree.at(pointer);
+ if (!value.isTextual() || !hasPath(camel, cli, "verify",
"logMessage")) {
+ continue;
+ }
+ List<String> escapes = new ArrayList<>();
+ String text =
REGEX_ESCAPE.matcher(value.asText()).replaceAll(m -> {
+ boolean pair = "\\".equals(m.group(1));
+ if (!pair) {
+ escapes.add(m.group());
+ }
+ return Matcher.quoteReplacement(pair ? m.group() :
m.group(1));
+ });
+ if (escapes.isEmpty()) {
+ continue;
+ }
+ String answer = where(pointer) + " is plain text, not a
regular expression: Citrus checks that the"
+ + " integration logged a line that contains it
as written, so the backslash"
+ + (escapes.size() == 1 ? "" : "es") + " in " +
and(escapes.stream().distinct().toList())
+ + " would have to be in the log too.";
+ String written = keyLines(pointer, TextNode.valueOf(text));
+ Node key = keyNode(pointer);
+ add(key != null ? line(key) : YamlPointerLines.line(root,
pointer, null),
+ answer + (written != null ? written : " Write
logMessage: " + quoted(text)));
+ }
+ }
+
/**
* The schema errors in words: what the noise of the branches of a
oneOf leaves, without the type errors of the
* values Citrus converts, and the keys an object does not take in one
message, with the keys it takes.
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 f33c3257660e..3ce668b62abe 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
@@ -25,6 +25,8 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.junit.jupiter.api.Named;
import org.junit.jupiter.api.Test;
@@ -44,11 +46,13 @@ import static org.assertj.core.api.Assertions.assertThat;
class CitrusTestChecksTest {
private static final Path ORDERS =
Path.of("src/test/resources/citrus-orders");
+ private static final ObjectMapper YAML = new ObjectMapper(new
YAMLFactory());
/** What is said of an action named log: what to write to log a message,
and to check the log of the integration. */
private static final String LOG_HINT = "echo: {message: \"...\"} writes a
message to the test log, and camel: {cli:"
+ " {verify: {integration:
\"<name>\", logMessage: \"...\"}}} checks that the"
- + " integration logged a line";
+ + " integration logged a line that
contains that text as written (plain"
+ + " text, not a pattern)";
/** 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"
@@ -485,6 +489,85 @@ class CitrusTestChecksTest {
return String.join("\n", lines);
}
+ /** A test that checks the log of the integration: the key of the camel
action, and the logMessage as written. */
+ private static final String VERIFY_LOG = """
+ name: orders.citrus.it
+ actions:
+ - camel:
+ %s:
+ verify:
+ integration: "orders"
+ logMessage: %s
+ """;
+
+ static Stream<Arguments> logMessagesWithRegexEscapes() {
+ return Stream.of(
+ // as the model wrote it in r32
+ Arguments.of("cli", "\"Order ORD-1001 accepted for C-482 with
2 line\\\\(s\\\\)\""),
+ // in single quotes and without quotes, the backslash is
written once
+ Arguments.of("cli", "'Order ORD-1001 accepted for C-482 with 2
line\\(s\\)'"),
+ Arguments.of("cli", "Order ORD-1001 accepted for C-482 with 2
line\\(s\\)"),
+ // jbang is the key Citrus had before cli
+ Arguments.of("jbang", "'Order ORD-1001 accepted for C-482 with
2 line\\(s\\)'"));
+ }
+
+ /**
+ * r32 of the local-model benchmark, test-citrus-orders step 1: in three
of seven runs the model checked the log
+ * line with a regular expression, logMessage: "... line\\(s\\)" (it had
first written assert-log: {pattern: ...}),
+ * and camel test run waited 60 attempts for a line with the backslashes
in it. logMessage is plain text: the line
+ * to write has the text without them, and written, the test is valid.
+ */
+ @ParameterizedTest(name = "{0}: {1}")
+ @MethodSource("logMessagesWithRegexEscapes")
+ void aLogMessageWithRegexEscapesIsPlainText(String cli, String value)
throws Exception {
+ String test = VERIFY_LOG.formatted(cli, value);
+ List<String> msgs = CitrusTestChecks.validate(test);
+
+ assertThat(msgs).containsExactly("Line 7: camel." + cli +
".verify.logMessage is plain text, not a regular"
+ + " expression: Citrus checks that
the integration logged a line that contains"
+ + " it as written, so the backslashes
in \\( and \\) would have to be in the"
+ + " log too. Write line 7 as:\n"
+ + " logMessage: \"Order
ORD-1001 accepted for C-482 with 2 line(s)\"");
+ String fixed = written(test, msgs.get(0));
+ assertThat(CitrusTestChecks.validate(fixed)).isEmpty();
+ assertThat(YAML.readTree(fixed).at("/actions/0/camel/" + cli +
"/verify/logMessage").asText())
+ .isEqualTo("Order ORD-1001 accepted for C-482 with 2 line(s)");
+ }
+
+ /**
+ * The line to write keeps a backslash before another character, as in a
Windows path; the text is then in single
+ * quotes, where YAML takes a backslash as written.
+ */
+ @Test
+ void theLineToWriteKeepsTheOtherBackslashes() throws Exception {
+ String test = VERIFY_LOG.formatted("cli", "'Wrote ORD-1001\\.json to
C:\\orders\\out'");
+ List<String> msgs = CitrusTestChecks.validate(test);
+
+ assertThat(msgs).containsExactly("Line 7: camel.cli.verify.logMessage
is plain text, not a regular expression:"
+ + " Citrus checks that the
integration logged a line that contains it as"
+ + " written, so the backslash in \\.
would have to be in the log too. Write"
+ + " line 7 as:\n"
+ + " logMessage: 'Wrote
ORD-1001.json to C:\\orders\\out'");
+ String fixed = written(test, msgs.get(0));
+ assertThat(CitrusTestChecks.validate(fixed)).isEmpty();
+
assertThat(YAML.readTree(fixed).at("/actions/0/camel/cli/verify/logMessage").asText())
+ .isEqualTo("Wrote ORD-1001.json to C:\\orders\\out");
+ }
+
+ /**
+ * Text without a regular expression escape is checked as written: a
backslash before another character (a Windows
+ * path, an escaped quote), and two backslashes before a (, as JSON text
in the log has them.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "\"Order ORD-1001 accepted for C-482 with 2 line(s)\"",
+ "'Wrote ORD-1001.json to C:\\orders\\out'",
+ "'Order \\\"ORD-1001\\\" accepted'",
+ "'{\"file\": \"C:\\\\orders\\\\(old)\"}'" })
+ void aLogMessageWithoutRegexEscapesIsValid(String value) {
+ assertThat(CitrusTestChecks.validate(VERIFY_LOG.formatted("cli",
value))).isEmpty();
+ }
+
@Test
void aTestCutOffInTheMiddleOfAnAction() throws Exception {
List<String> msgs = validate(ORDERS.resolve("cut-off.citrus.it.yaml"));