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 7de03cbc6c534..ee297b9db3c3a 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 @@ -85,6 +85,13 @@ The same default is now also applied to the `ClientConfig` that Camel builds for endpoints, when neither a referenced `ClientConfig` nor `hazelcastConfigUri` is supplied. Client mode previously behaved differently from node mode for an otherwise identical endpoint configuration. +=== 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. + === camel-mail `MimeMultipartDataFormat` now uses `MailHeaderFilterStrategy` instead of a plain 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..eac4a6e96dc53 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,23 @@ 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 +---- + +`--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 @@ -940,4 +957,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/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 92fdfaebe6c98..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 @@ -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; @@ -82,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; @@ -101,6 +115,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 +213,68 @@ 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 + "'"); + } + + /** + * 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"); @@ -209,16 +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"); - 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"); - } + configureRecording(); recordingManager.init(record != null); @@ -649,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/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/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 new file mode 100644 index 0000000000000..9b47a877c0407 --- /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,122 @@ +/* + * 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 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*} 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()); + + @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); + } + + @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 new file mode 100644 index 0000000000000..fa2e495ae9744 --- /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,168 @@ +/* + * 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 org.junitpioneer.jupiter.ClearSystemProperty; + +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. + */ +// 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 + 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(); + } + } + + @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 { + } + } +} 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(); + } +}