From 9f12b6a7977ddc86b37f7918dd80ed7f3ef27f01 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:42:14 -0400 Subject: [PATCH 1/2] CAMEL-24397: camel-tui - Fix --record producing no cast file TamboUI applies recording in exactly one place, BackendFactory.create(), which calls RecordingConfig.load() (installing the System.out capture and the shutdown hook that writes the cast) and wraps the backend in RecordingBackend (which loads the tape via InteractionPlayer). TuiRunner.create() only calls that factory when no explicit backend is configured. TuiBackendHelper always supplies an explicit JLineBackend, so recording was never engaged: --record replayed no tape, wrote no .cast file, and still exited cleanly. The explicit backend is deliberate and has to stay, because ServiceLoader auto-discovery can otherwise pick the Aesh backend that --web puts on the classpath. So apply the recording wrapper ourselves instead of reverting to auto-discovery. Also replace the hardcoded 200x50 recording geometry with --record-size, --record-fps and --record-duration. 200 columns is too wide to embed in a documentation page. Defaults are unchanged. Co-Authored-By: Claude Opus 5 --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 25 +++ .../jbang/core/commands/tui/CamelMonitor.java | 47 ++++- .../core/commands/tui/TuiBackendHelper.java | 24 ++- .../tui/CamelMonitorRecordOptionsTest.java | 72 ++++++++ .../tui/TuiBackendHelperRecordingTest.java | 162 ++++++++++++++++++ 5 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc index 9c7192eab078f..87dce0b04118e 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -892,6 +892,19 @@ https://asciinema.org/[Asciinema] `.cast` recording: camel tui --record=demo.tape ---- +The `.cast` file is written next to the tape, with the `.tape` suffix replaced by `.cast`. +Recording is headless: the TUI is driven entirely by the tape rather than by your terminal, +so no keyboard input is read and nothing is drawn on screen. + +The recorded terminal is 200x50 by default, which is wider than a documentation page can +display. Use `--record-size` to record at a size that fits, and `--record-fps` or +`--record-duration` to control the capture rate and the cut-off: + +[source,bash] +---- +camel tui --record=demo.tape --record-size=160x44 --record-fps=15 +---- + === Converting to GIF Convert recordings using https://github.com/asciinema/agg[agg] (for `.cast` files) or @@ -940,4 +953,16 @@ vhs demo.tape # .tape -> .gif | `--record` | Replay a `.tape` file and record the session to an Asciinema `.cast` file. | + +| `--record-size` +| Size of the recorded terminal for `--record`, as `x`. +| `200x50` + +| `--record-fps` +| Frames per second captured by `--record`. +| `10` + +| `--record-duration` +| Maximum duration in milliseconds captured by `--record`. +| `120000` |=== diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index 92fdfaebe6c98..431caa91acd2d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -27,6 +27,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Locale; import java.util.Optional; import java.util.Queue; import java.util.concurrent.CompletableFuture; @@ -101,6 +102,21 @@ public class CamelMonitor extends CamelCommand { arity = "0..1") String record; + @CommandLine.Option(names = { "--record-size" }, + description = "Size of the recorded terminal for --record, as x (default: ${DEFAULT-VALUE})", + defaultValue = "200x50") + String recordSize = "200x50"; + + @CommandLine.Option(names = { "--record-fps" }, + description = "Frames per second captured by --record (default: ${DEFAULT-VALUE})", + defaultValue = "10") + int recordFps = 10; + + @CommandLine.Option(names = { "--record-duration" }, + description = "Maximum duration in milliseconds captured by --record (default: ${DEFAULT-VALUE})", + defaultValue = "120000") + int recordDuration = 120000; + @CommandLine.Option(names = { "--mcp" }, description = "Enable embedded MCP server for AI agent access to the TUI") boolean mcp; @@ -184,6 +200,28 @@ public CamelMonitor(CamelJBangMain main, ClassLoader classLoader) { this.classLoader = classLoader; } + /** + * Parses a {@code --record-size} value such as {@code 160x44} into {@code [cols, rows]}. + */ + int[] parseRecordSize(String size) { + String[] parts = size == null ? new String[0] : size.toLowerCase(Locale.ROOT).split("x", -1); + if (parts.length == 2) { + try { + int cols = Integer.parseInt(parts[0].trim()); + int rows = Integer.parseInt(parts[1].trim()); + if (cols > 0 && rows > 0) { + return new int[] { cols, rows }; + } + } catch (NumberFormatException e) { + // fall through to the parameter error below + } + } + throw new CommandLine.ParameterException( + new CommandLine(this), + "Invalid value for option '--record-size': expected 'x' with positive numbers, was '" + + size + "'"); + } + @Override public Integer doCall() throws Exception { System.setProperty("java.awt.headless", "true"); @@ -212,12 +250,13 @@ public Integer doCall() throws Exception { if (record != null) { Path tapeFile = Path.of(record); Path castFile = Path.of(record.replaceAll("\\.tape$", "") + ".cast"); + int[] size = parseRecordSize(recordSize); System.setProperty("tamboui.record", castFile.toAbsolutePath().toString()); System.setProperty("tamboui.record.config", tapeFile.toAbsolutePath().toString()); - System.setProperty("tamboui.record.width", "200"); - System.setProperty("tamboui.record.height", "50"); - System.setProperty("tamboui.record.duration", "120000"); - System.setProperty("tamboui.record.fps", "10"); + System.setProperty("tamboui.record.width", String.valueOf(size[0])); + System.setProperty("tamboui.record.height", String.valueOf(size[1])); + System.setProperty("tamboui.record.duration", String.valueOf(recordDuration)); + System.setProperty("tamboui.record.fps", String.valueOf(recordFps)); } recordingManager.init(record != null); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java index d3abe4a7cda24..bbb0b052957f9 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java @@ -17,6 +17,8 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import dev.tamboui.backend.jline3.JLineBackend; +import dev.tamboui.internal.record.RecordingBackend; +import dev.tamboui.internal.record.RecordingConfig; import dev.tamboui.terminal.Backend; import dev.tamboui.tui.TuiConfig; import dev.tamboui.tui.TuiRunner; @@ -35,10 +37,28 @@ static TuiRunner createTuiRunner() throws Exception { // classpath (for --web), auto-discovery can pick AeshBackend for the local session too, // which drives a native PosixSysTerminal that doesn't shut down cleanly here. JLineBackend backend = activeTerminal != null ? new JLineBackend(activeTerminal) : new JLineBackend(); - return TuiRunner.create(TuiConfig.builder().backend(backend).mouseCapture(true).build()); + return createTuiRunner(backend); } static TuiRunner createTuiRunner(Backend backend) throws Exception { - return TuiRunner.create(TuiConfig.builder().backend(backend).mouseCapture(true).build()); + return TuiRunner.create(TuiConfig.builder().backend(applyRecording(backend)).mouseCapture(true).build()); + } + + /** + * Wraps the backend for Asciinema recording when {@code --record} configured the {@code tamboui.record*} system + * properties. + *

+ * TamboUI normally does this inside {@code BackendFactory.create()}, but {@code TuiRunner} only calls that factory + * when no explicit backend is configured. Because we must pass an explicit backend (see + * {@link #createTuiRunner()}), the wrapping has to be done here instead, otherwise {@code --record} exits cleanly + * without replaying the tape or writing a {@code .cast} file. + */ + static Backend applyRecording(Backend backend) { + // Guard on isEnabled() first: load() caches its result process-wide and installs a System.out capture + if (!RecordingConfig.isEnabled()) { + return backend; + } + RecordingConfig config = RecordingConfig.load(); + return config != null ? new RecordingBackend(backend, config) : backend; } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java new file mode 100644 index 0000000000000..83bc71c32a74a --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java @@ -0,0 +1,72 @@ +/* + * 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 org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for the {@code --record-size} value parsing. + *

+ * The recording geometry used to be hardcoded at 200x50, which is too wide to embed in a documentation page, so it is + * now configurable. A bad value must be rejected up front with a clear message rather than silently recording at the + * wrong size, because the mistake would otherwise only surface once the cast is rendered. + */ +class CamelMonitorRecordOptionsTest { + + private final CamelMonitor monitor = new CamelMonitor(new CamelJBangMain(), getClass().getClassLoader()); + + @Test + void parsesColumnsAndRows() { + assertThat(monitor.parseRecordSize("160x44")).containsExactly(160, 44); + } + + @Test + void acceptsUppercaseSeparatorAndSurroundingSpaces() { + assertThat(monitor.parseRecordSize(" 160 X 44 ")).containsExactly(160, 44); + } + + @Test + void rejectsNonPositiveDimensions() { + assertThatThrownBy(() -> monitor.parseRecordSize("0x44")) + .isInstanceOf(CommandLine.ParameterException.class) + .hasMessageContaining("--record-size"); + } + + @Test + void rejectsMalformedValues() { + assertThatThrownBy(() -> monitor.parseRecordSize("160")) + .isInstanceOf(CommandLine.ParameterException.class) + .hasMessageContaining("--record-size"); + assertThatThrownBy(() -> monitor.parseRecordSize("wide x tall")) + .isInstanceOf(CommandLine.ParameterException.class) + .hasMessageContaining("--record-size"); + } + + @Test + void defaultsKeepTheHistoricRecordingGeometry() { + // 200x50 was the previously hardcoded value; keeping it as the default means this change adds + // an override without altering the output of an existing command line. + assertThat(monitor.parseRecordSize(monitor.recordSize)).containsExactly(200, 50); + assertThat(monitor.recordFps).isEqualTo(10); + assertThat(monitor.recordDuration).isEqualTo(120000); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java new file mode 100644 index 0000000000000..a6a76a8607f14 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java @@ -0,0 +1,162 @@ +/* + * 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.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import dev.tamboui.buffer.DiffResult; +import dev.tamboui.internal.record.AnsiTerminalCapture; +import dev.tamboui.layout.Position; +import dev.tamboui.layout.Size; +import dev.tamboui.terminal.Backend; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@code camel tui --record} actually engages TamboUI's recording backend. + *

+ * TamboUI only wraps a backend for recording inside {@code BackendFactory.create()}, and {@code TuiRunner} calls that + * factory only when no explicit backend is configured. Since camel-tui must supply an explicit + * {@link dev.tamboui.backend.jline3.JLineBackend} (auto-discovery would otherwise pick the Aesh backend that is on the + * classpath for {@code --web}), the recording wrapper is never applied unless camel-tui applies it itself. When that + * wrapping is missing, {@code --record} replays no tape and writes no {@code .cast} file, yet still exits cleanly, so + * only a test like this one catches the regression. + */ +class TuiBackendHelperRecordingTest { + + @TempDir + Path tempDir; + + @AfterEach + void tearDown() { + // load() installs a System.out capture; restore the real stream so other tests are unaffected + if (AnsiTerminalCapture.isInstalled()) { + AnsiTerminalCapture.uninstall(); + } + System.clearProperty("tamboui.record"); + System.clearProperty("tamboui.record.config"); + System.clearProperty("tamboui.record.width"); + System.clearProperty("tamboui.record.height"); + } + + @Test + void withoutRecordOptionTheBackendIsHandedToTuiRunnerUntouched() { + Backend original = new NoopBackend(); + + Backend result = TuiBackendHelper.applyRecording(original); + + assertThat(result).isSameAs(original); + } + + @Test + void withRecordOptionTheBackendIsWrappedAndSizedFromTheRecordingConfig() throws Exception { + Path tape = tempDir.resolve("demo.tape"); + Files.writeString(tape, "Sleep 100ms\nType \"q\"\n"); + System.setProperty("tamboui.record", tempDir.resolve("demo.cast").toString()); + System.setProperty("tamboui.record.config", tape.toString()); + System.setProperty("tamboui.record.width", "120"); + System.setProperty("tamboui.record.height", "30"); + + Backend result = TuiBackendHelper.applyRecording(new NoopBackend()); + + // A recording backend reports the configured cast dimensions rather than the real terminal + // size; asserting on those proves the configuration was applied, not merely that some + // wrapper was returned. + assertThat(result).isNotInstanceOf(NoopBackend.class); + assertThat(result.size()).isEqualTo(new Size(120, 30)); + } + + /** + * Minimal {@link Backend} stand-in. Only {@link #size()} needs a meaningful value, so that the test fails if the + * recording dimensions are taken from the delegate instead of the config. + */ + private static final class NoopBackend implements Backend { + + @Override + public void draw(DiffResult diff) throws IOException { + } + + @Override + public void flush() throws IOException { + } + + @Override + public void clear() throws IOException { + } + + @Override + public Size size() throws IOException { + return new Size(80, 24); + } + + @Override + public void showCursor() throws IOException { + } + + @Override + public void hideCursor() throws IOException { + } + + @Override + public Position getCursorPosition() throws IOException { + return new Position(0, 0); + } + + @Override + public void setCursorPosition(Position position) throws IOException { + } + + @Override + public void enterAlternateScreen() throws IOException { + } + + @Override + public void leaveAlternateScreen() throws IOException { + } + + @Override + public void enableRawMode() throws IOException { + } + + @Override + public void disableRawMode() throws IOException { + } + + @Override + public void onResize(Runnable handler) { + } + + @Override + public int read(int timeoutMs) throws IOException { + return -2; + } + + @Override + public int peek(int timeoutMs) throws IOException { + return -2; + } + + @Override + public void close() throws IOException { + } + } +} From bcda723a83c0569e026a9fcb9bed4da598579703 Mon Sep 17 00:00:00 2001 From: Adriano Machado <60320+ammachado@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:17:15 -0400 Subject: [PATCH 2/2] CAMEL-24397: camel-tui - Address review feedback on --record Forward --record-size, --record-fps and --record-duration from TuiCommand to CamelMonitor. TuiCommand is the entry point registered by the TUI plugin, so the options documented in the user manual were rejected as unknown options on the primary CLI path. Reject --record combined with --web. The tamboui.record* system properties are process-wide, so every browser session spawned by TuiWebServer would be wrapped for recording and write to the same cast file. Clear the tamboui.record* properties when the session that set them ends, so a TUI backend created later in the same JVM is not wrapped for recording again. Manage the recording system properties in tests with junit-pioneer, so every key RecordingConfig reads is restored, including fps and duration. Co-Authored-By: Claude Opus 5 --- .../pages/camel-4x-upgrade-guide-4_23.adoc | 7 ++ .../modules/ROOT/pages/camel-jbang-tui.adoc | 4 + .../camel-jbang-plugin-tui/pom.xml | 6 ++ .../jbang/core/commands/tui/CamelMonitor.java | 69 ++++++++++++++--- .../jbang/core/commands/tui/TuiCommand.java | 41 +++++++++- .../tui/CamelMonitorRecordOptionsTest.java | 52 ++++++++++++- .../tui/TuiBackendHelperRecordingTest.java | 14 +++- .../tui/TuiCommandRecordOptionsTest.java | 76 +++++++++++++++++++ 8 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommandRecordOptionsTest.java diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index a43c68a08f5d5..af069588912db 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -129,3 +129,10 @@ now also stops the route. Routes that relied on steps after these processors running for unauthenticated requests must be restructured. The authenticated paths are unchanged: a successfully authenticated request continues through the rest of the route exactly as before, and `OAuthLogoutProcessor` is unchanged. + +=== camel-jbang (TUI) + +`camel tui --record` is now rejected when combined with `--web`. The recording configuration applies +to the whole process, so a browser session served by `--web` would be recorded into the same `.cast` +file as the local session. Previously the combination was accepted, but recording never produced any +output, so run the two modes in separate processes instead. \ No newline at end of file diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc index 87dce0b04118e..eac4a6e96dc53 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -905,6 +905,10 @@ display. Use `--record-size` to record at a size that fits, and `--record-fps` o camel tui --record=demo.tape --record-size=160x44 --record-fps=15 ---- +`--record` cannot be combined with `--web`. Recording drives a headless TUI from the tape, and +the recording configuration applies to the whole process, so every browser session would be +recorded into the same `.cast` file. Camel rejects the combination with an error instead. + === Converting to GIF Convert recordings using https://github.com/asciinema/agg[agg] (for `.cast` files) or diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml index 241adb18dbad5..9882e20e78a98 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml @@ -130,6 +130,12 @@ ${awaitility-version} test + + org.junit-pioneer + junit-pioneer + ${junit-pioneer-version} + test + org.assertj assertj-core diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index 431caa91acd2d..197aa3e99377e 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -83,6 +83,19 @@ public class CamelMonitor extends CamelCommand { private static final Logger LOG = System.getLogger(CamelMonitor.class.getName()); private static final long DEFAULT_REFRESH_MS = 500; + /** + * The TamboUI system properties {@code --record} configures. They are process-wide, so the session that sets them + * clears them again on the way out; otherwise a later TUI backend created in the same JVM would still see recording + * enabled via {@code RecordingConfig.isEnabled()}. + */ + static final List RECORD_PROPERTIES = List.of( + "tamboui.record", + "tamboui.record.config", + "tamboui.record.width", + "tamboui.record.height", + "tamboui.record.duration", + "tamboui.record.fps"); + // Compact tab bar (10 labels + 9 "|" dividers) needs 88 chars — that is the true minimum private static final int MIN_WIDTH = 88; private static final int MIN_HEIGHT = 24; @@ -222,6 +235,46 @@ int[] parseRecordSize(String size) { + size + "'"); } + /** + * Hands the {@code --record*} options to TamboUI through the {@link #RECORD_PROPERTIES} system properties, which is + * the only way TamboUI accepts a recording configuration. + */ + void configureRecording() { + if (record == null) { + return; + } + if (web) { + // The properties below are process-wide, so every browser session spawned by TuiWebServer would be + // wrapped for recording too, all writing the same cast file. The two modes are also conceptually + // exclusive: --record drives a headless TUI from a tape rather than from a connected terminal. + throw new CommandLine.ParameterException( + new CommandLine(this), + "Option '--record' cannot be combined with '--web': recording replays a tape headlessly " + + "and would be inherited by every browser session"); + } + Path tapeFile = Path.of(record); + Path castFile = Path.of(record.replaceAll("\\.tape$", "") + ".cast"); + int[] size = parseRecordSize(recordSize); + System.setProperty("tamboui.record", castFile.toAbsolutePath().toString()); + System.setProperty("tamboui.record.config", tapeFile.toAbsolutePath().toString()); + System.setProperty("tamboui.record.width", String.valueOf(size[0])); + System.setProperty("tamboui.record.height", String.valueOf(size[1])); + System.setProperty("tamboui.record.duration", String.valueOf(recordDuration)); + System.setProperty("tamboui.record.fps", String.valueOf(recordFps)); + } + + /** + * Undoes {@link #configureRecording()} at the end of the session that ran it. + *

+ * The already-loaded {@code RecordingConfig} keeps its own copy, so the shutdown hook still writes the cast file; + * clearing only stops a TUI backend created later in the same JVM from being wrapped for recording again. + */ + void clearRecordingProperties() { + for (String key : RECORD_PROPERTIES) { + System.clearProperty(key); + } + } + @Override public Integer doCall() throws Exception { System.setProperty("java.awt.headless", "true"); @@ -247,17 +300,7 @@ public Integer doCall() throws Exception { } // Configure TamboUI recording if --record is specified - if (record != null) { - Path tapeFile = Path.of(record); - Path castFile = Path.of(record.replaceAll("\\.tape$", "") + ".cast"); - int[] size = parseRecordSize(recordSize); - System.setProperty("tamboui.record", castFile.toAbsolutePath().toString()); - System.setProperty("tamboui.record.config", tapeFile.toAbsolutePath().toString()); - System.setProperty("tamboui.record.width", String.valueOf(size[0])); - System.setProperty("tamboui.record.height", String.valueOf(size[1])); - System.setProperty("tamboui.record.duration", String.valueOf(recordDuration)); - System.setProperty("tamboui.record.fps", String.valueOf(recordFps)); - } + configureRecording(); recordingManager.init(record != null); @@ -688,6 +731,10 @@ public void resetIntegrationTabState() { } deleteMcpJson(mcpJsonFile); this.runner = null; + if (record != null) { + // Only the session that set the properties clears them again + clearRecordingProperties(); + } } return 0; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java index b5e0b9fc3f807..fdbc5c91e5318 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java @@ -59,6 +59,21 @@ public class TuiCommand extends CamelCommand { arity = "0..1") String record; + @CommandLine.Option(names = { "--record-size" }, + description = "Size of the recorded terminal for --record, as x (default: ${DEFAULT-VALUE})", + defaultValue = "200x50") + String recordSize = "200x50"; + + @CommandLine.Option(names = { "--record-fps" }, + description = "Frames per second captured by --record (default: ${DEFAULT-VALUE})", + defaultValue = "10") + int recordFps = 10; + + @CommandLine.Option(names = { "--record-duration" }, + description = "Maximum duration in milliseconds captured by --record (default: ${DEFAULT-VALUE})", + defaultValue = "120000") + int recordDuration = 120000; + @CommandLine.Option(names = { "--theme" }, description = "Color theme: dark or light (overrides persisted preference for this session)", completionCandidates = ThemeModeCompletionCandidates.class) @@ -71,6 +86,17 @@ public TuiCommand(CamelJBangMain main, ClassLoader classLoader) { @Override public Integer doCall() throws Exception { + CamelMonitor cmd = new CamelMonitor(getMain(), classLoader); + return new CommandLine(cmd).execute(buildArgs().toArray(String[]::new)); + } + + /** + * Builds the {@link CamelMonitor} command line this command delegates to. + *

+ * Every option declared here must be forwarded, otherwise the option is silently accepted and then ignored. Only + * non-default values are passed on, so the delegate keeps applying its own defaults. + */ + List buildArgs() { List args = new ArrayList<>(); if (name != null) { args.add(name); @@ -97,11 +123,22 @@ public Integer doCall() throws Exception { args.add("--record"); args.add(record); } + if (!"200x50".equals(recordSize)) { + args.add("--record-size"); + args.add(recordSize); + } + if (recordFps != 10) { + args.add("--record-fps"); + args.add(String.valueOf(recordFps)); + } + if (recordDuration != 120000) { + args.add("--record-duration"); + args.add(String.valueOf(recordDuration)); + } if (theme != null) { args.add("--theme"); args.add(theme); } - CamelMonitor cmd = new CamelMonitor(getMain(), classLoader); - return new CommandLine(cmd).execute(args.toArray(String[]::new)); + return args; } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java index 83bc71c32a74a..9b47a877c0407 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorRecordOptionsTest.java @@ -18,18 +18,27 @@ import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.ClearSystemProperty; import picocli.CommandLine; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Tests for the {@code --record-size} value parsing. + * Tests for the {@code --record*} options. *

* The recording geometry used to be hardcoded at 200x50, which is too wide to embed in a documentation page, so it is * now configurable. A bad value must be rejected up front with a clear message rather than silently recording at the * wrong size, because the mistake would otherwise only surface once the cast is rendered. */ +// The options are handed to TamboUI through process-wide system properties, so the tests that exercise them have to +// leave the JVM as they found it; junit-pioneer restores the original values after each test. +@ClearSystemProperty(key = "tamboui.record") +@ClearSystemProperty(key = "tamboui.record.config") +@ClearSystemProperty(key = "tamboui.record.width") +@ClearSystemProperty(key = "tamboui.record.height") +@ClearSystemProperty(key = "tamboui.record.duration") +@ClearSystemProperty(key = "tamboui.record.fps") class CamelMonitorRecordOptionsTest { private final CamelMonitor monitor = new CamelMonitor(new CamelJBangMain(), getClass().getClassLoader()); @@ -69,4 +78,45 @@ void defaultsKeepTheHistoricRecordingGeometry() { assertThat(monitor.recordFps).isEqualTo(10); assertThat(monitor.recordDuration).isEqualTo(120000); } + + @Test + void rejectsRecordingCombinedWithTheWebTerminal() { + // tamboui.record* is JVM-wide, so a browser session started by TuiWebServer would silently inherit the + // local session's recording and write to the same cast file. Failing up front beats that surprise. + monitor.record = "demo.tape"; + monitor.web = true; + + assertThatThrownBy(monitor::configureRecording) + .isInstanceOf(CommandLine.ParameterException.class) + .hasMessageContaining("--record") + .hasMessageContaining("--web"); + } + + @Test + void configuringRecordingSetsEveryPropertyThatIsClearedAgainAfterwards() { + // The set and the clear list have to stay in sync: a property added to configureRecording() but missing + // from RECORD_PROPERTIES would keep recording enabled for the rest of the JVM's life. + monitor.record = "demo.tape"; + monitor.recordSize = "160x44"; + + monitor.configureRecording(); + + assertThat(CamelMonitor.RECORD_PROPERTIES) + .allSatisfy(key -> assertThat(System.getProperty(key)).as(key).isNotNull()); + assertThat(System.getProperty("tamboui.record.width")).isEqualTo("160"); + assertThat(System.getProperty("tamboui.record.height")).isEqualTo("44"); + + monitor.clearRecordingProperties(); + + assertThat(CamelMonitor.RECORD_PROPERTIES) + .allSatisfy(key -> assertThat(System.getProperty(key)).as(key).isNull()); + } + + @Test + void leavesTheRecordingPropertiesAloneWithoutTheRecordOption() { + monitor.configureRecording(); + + assertThat(CamelMonitor.RECORD_PROPERTIES) + .allSatisfy(key -> assertThat(System.getProperty(key)).as(key).isNull()); + } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java index a6a76a8607f14..fa2e495ae9744 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelperRecordingTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junitpioneer.jupiter.ClearSystemProperty; import static org.assertj.core.api.Assertions.assertThat; @@ -41,6 +42,15 @@ * wrapping is missing, {@code --record} replays no tape and writes no {@code .cast} file, yet still exits cleanly, so * only a test like this one catches the regression. */ +// RecordingConfig.load() also reads fps and duration, so every key camel-tui sets has to be managed here: a value +// leaking out of this class would silently reconfigure recording in an unrelated test. junit-pioneer clears each key +// before the test and restores the original value afterwards, which also covers values the test body sets itself. +@ClearSystemProperty(key = "tamboui.record") +@ClearSystemProperty(key = "tamboui.record.config") +@ClearSystemProperty(key = "tamboui.record.width") +@ClearSystemProperty(key = "tamboui.record.height") +@ClearSystemProperty(key = "tamboui.record.duration") +@ClearSystemProperty(key = "tamboui.record.fps") class TuiBackendHelperRecordingTest { @TempDir @@ -52,10 +62,6 @@ void tearDown() { if (AnsiTerminalCapture.isInstalled()) { AnsiTerminalCapture.uninstall(); } - System.clearProperty("tamboui.record"); - System.clearProperty("tamboui.record.config"); - System.clearProperty("tamboui.record.width"); - System.clearProperty("tamboui.record.height"); } @Test diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommandRecordOptionsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommandRecordOptionsTest.java new file mode 100644 index 0000000000000..3dfb57e50cded --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommandRecordOptionsTest.java @@ -0,0 +1,76 @@ +/* + * 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.List; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@code camel tui} forwards the recording options to {@link CamelMonitor}. + *

+ * {@link TuiCommand} is the entry point registered by the TUI plugin, but the options are implemented on + * {@link CamelMonitor}, which it delegates to by rebuilding a command line. An option declared on only one of the two + * fails in a way no compiler catches: declared only on {@code CamelMonitor} it is rejected as an unknown option, + * declared only on {@code TuiCommand} it is accepted and then silently dropped. This test pins the forwarding so the + * documented {@code camel tui --record=demo.tape --record-size=160x44 --record-fps=15} keeps working. + */ +class TuiCommandRecordOptionsTest { + + @Test + void forwardsAllRecordingOptions() { + assertThat(buildArgs("--record=demo.tape", "--record-size=160x44", "--record-fps=15", "--record-duration=30000")) + .containsExactly( + "--record", "demo.tape", + "--record-size", "160x44", + "--record-fps", "15", + "--record-duration", "30000"); + } + + @Test + void omitsRecordingOptionsLeftAtTheirDefault() { + // Passing the defaults through would be harmless but noisy; more importantly the delegate must keep + // owning the default values, so they are only declared in one place. + assertThat(buildArgs("--record=demo.tape")).containsExactly("--record", "demo.tape"); + } + + @Test + void everyRecordingOptionIsAcceptedByTheDelegate() { + // The forwarded command line is only useful if CamelMonitor understands it — the bug this guards against + // was TuiCommand accepting --record-size and CamelMonitor never seeing it. + CamelMonitor monitor = new CamelMonitor(new CamelJBangMain(), getClass().getClassLoader()); + String[] args = buildArgs("--record=demo.tape", "--record-size=160x44", "--record-fps=15", + "--record-duration=30000").toArray(String[]::new); + + new CommandLine(monitor).parseArgs(args); + + assertThat(monitor.record).isEqualTo("demo.tape"); + assertThat(monitor.recordSize).isEqualTo("160x44"); + assertThat(monitor.recordFps).isEqualTo(15); + assertThat(monitor.recordDuration).isEqualTo(30000); + } + + private List buildArgs(String... args) { + TuiCommand command = new TuiCommand(new CamelJBangMain(), getClass().getClassLoader()); + new CommandLine(command).parseArgs(args); + return command.buildArgs(); + } +}