This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch quick-fix/tui-tape-player in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4ace8d590ecaa1964b97e240eb5dc6153745dc4f Author: Claus Ibsen <[email protected]> AuthorDate: Sat Oct 10 14:55:23 2026 +0200 chore: camel-jbang - camel tui --record plays the tape itself, with commands for captions, highlights and tabs The tape is now played by Camel instead of handed to the recording backend, so F-keys and modifiers work, Wait waits for text on the screen, and Camel-only commands (Caption, Highlight, ClearDrawing, Tab, Integration, Action, Theme) make it easy to script a demo. Documented as a command reference with an example tape, aimed at AI agents writing and recording demos. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/images/jbang/camel-tui-tape-player.gif | Bin 0 -> 305206 bytes .../ROOT/pages/camel-jbang-tui-ai-agents.adoc | 4 +- .../ROOT/pages/camel-jbang-tui-settings.adoc | 161 ++++++- .../modules/ROOT/pages/camel-jbang-tui.adoc | 2 +- .../jbang-commands/camel-jbang-tui-monitor.adoc | 2 +- .../ROOT/pages/jbang-commands/camel-jbang-tui.adoc | 2 +- .../camel-jbang-plugin-commands-metadata.json | 2 +- .../dsl/jbang/core/commands/tui/CamelMonitor.java | 56 ++- .../dsl/jbang/core/commands/tui/TapePlayer.java | 462 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/TuiCommand.java | 2 +- .../tui/CamelMonitorRecordOptionsTest.java | 38 +- .../jbang/core/commands/tui/TapePlayerTest.java | 237 +++++++++++ 12 files changed, 945 insertions(+), 23 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-tape-player.gif b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-tape-player.gif new file mode 100644 index 000000000000..ce90576a81b3 Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-tape-player.gif differ diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc index fd312ae018d1..8e95e6e420f7 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc @@ -182,7 +182,9 @@ by purpose: * *Edit* -- write a source file in the integration's source directory (`camel_write_file`), see below * *Annotate* -- locate text and diagram nodes by coordinates, draw shapes (boxes, highlights, arrows, underlines, text labels), show captions with typewriter animation -* *Present* -- take screenshots, record tape sessions, control demo pacing +* *Present* -- take screenshots, record tape sessions, control demo pacing. To record a demo as a GIF, + an agent can also write a `.tape` file using the same commands and play it with `camel tui --record`, see + xref:camel-jbang-tui-settings.adoc#_scripted_recording[Scripted Recording] == Editing source files from an AI agent diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc index 526f99653d19..f82551a0c67b 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-settings.adoc @@ -284,27 +284,38 @@ same as if you'd run `camel tui` again in a new terminal. == Recording Demos -The TUI can record terminal sessions for demos and documentation. +The TUI can record its sessions for demos and documentation. A session is scripted as a `.tape` +file, in the format of https://github.com/charmbracelet/vhs[VHS], which the TUI plays and records +to an https://asciinema.org/[Asciinema] `.cast` file. The `.cast` file then converts to an +animated GIF. === Live Recording Use the *F2* actions menu and select *Start Tape Recording* to start recording your session as a `.tape` file. Select *Stop Tape Recording* to stop. The tape captures your keystrokes -with timing, producing a script that can be replayed or converted to an animated GIF. +with timing, producing a script that can be played back with `--record` (see below), edited, +or converted to an animated GIF. === Scripted Recording -Use the `--record` flag to replay a `.tape` file in headless mode and produce an -https://asciinema.org/[Asciinema] `.cast` recording: +Use the `--record` flag to play a `.tape` file in the TUI and record what happens on screen: [source,bash] ---- -camel tui --record=demo.tape +camel tui orders --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. +so no keyboard input is read and nothing is drawn on screen. The integrations are the ones +running on your machine, as with a plain `camel tui`, so start them before recording, and +name the integration to select it from the start (`orders` above). + +The tape is played by the TUI itself: the keys go to the TUI as if typed, and on top of the +VHS commands there are commands for the TUI, such as showing a caption, highlighting text on the +screen, or switching tab by name. When the tape ends, the TUI quits and the recording is saved. +Problems in the tape, such as an unknown command or a `Wait` that timed out, are printed as +`Tape: ...` warnings, and the rest of the tape still plays. 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 @@ -312,20 +323,148 @@ display. Use `--record-size` to record at a size that fits, and `--record-fps` o [source,bash] ---- -camel tui --record=demo.tape --record-size=160x44 --record-fps=15 +camel tui orders --record=demo.tape --record-size=120x32 --record-duration=60000 ---- +`--record-duration` is in milliseconds and stops the recording even when the tape is still +playing, so give a long tape enough time. + `--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. +==== Tape commands + +A tape has one command per line. Lines starting with `#` are comments. Durations are written +as `500ms`, `2s` or `1.5s`; a bare number is seconds. Text is in double quotes, where `\n` is +a new line and `\"` a quote. Most commands take a timing as `Command@duration`. + +[cols="2,5",options="header"] +|=== +| Command | Description + +| `Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `Insert`, `Space`, `Up`, `Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `F1` .. `F12` +| Presses the key. A count repeats it (`Down 3`), and `@` sets the gap between the presses +(`Down@300ms 3`, default 50ms). + +| `Ctrl+<key>`, `Alt+<key>`, `Shift+<key>` +| Presses the key with the modifiers, which can be combined (`Ctrl+Shift+Up`). The key is a +key name or a single character: `Ctrl+t`, `Shift+F8`, `Shift+Tab`. + +| `Type "text"` +| Types the text, one character at a time, 50ms apart. `Type@100ms "text"` types slower. +A `\n` in the text presses *Enter*, and `\t` presses *Tab*. A single letter +key such as `q` is typed with `Type "q"`. + +| `Sleep <duration>` +| Pauses the tape, so the viewer can read the screen. + +| `Wait /regex/` +| Waits until a line on the screen matches the regular expression, up to 15 seconds +(`Wait@30s /orders/` waits longer). `Wait+Screen /regex/` matches the whole screen, with the +lines separated by new lines, instead of each line. Use a `Wait` after a key that starts something +slow, rather than guessing a `Sleep`. + +| `Screenshot` +| Saves a screenshot of the screen, as the *F2* actions menu does. + +| `Source other.tape` +| Plays the commands of another tape, relative to this one. Useful for a common opening. + +| `Caption "text"` +| Shows a caption on the screen with a typewriter animation, which stays until a key is pressed. +`Caption@4s "text"` closes it after 4 seconds, and then keys go to the TUI while it shows. +Use `\n` to break lines. + +| `Highlight "text"` +| Highlights the first place on the screen showing the text, such as a key in the footer or a +node in the diagram. `Highlight@3s "text"` removes it after 3 seconds. Several highlights can be on +the screen at the same time. + +| `ClearDrawing` +| Removes the highlights. + +| `Tab "name"` +| Switches to the tab, such as `Tab "Log"` or `Tab "Diagram"`, without pressing its number key. + +| `Integration "name"` +| Selects the integration by its name or PID. + +| `Action "name"` +| Runs an entry of the *F2* actions menu by its name, such as `show-keystrokes`, `reset-stats` +or `next-theme`, or by its menu label. + +| `Theme "name"` +| Switches the theme, such as `Theme "dracula"`. +|=== + +The `Set`, `Output` and `Require` commands of VHS are skipped: the recording takes its size and +file from the `--record` options. `Hide` and `Show` are not supported, as everything the TUI +shows is recorded. + +==== Writing a tape + +The tape below opens the Diagram tab of the `orders` integration, explains the screen with +captions and a highlight, and asks the AI panel a question. Every step that depends on the TUI +being ready waits for it on the screen first, so the tape plays the same on a slow machine: + +[source,text] +---- +# wait for the integration to be discovered +Wait@30s /orders/ +Caption@3s "The Camel TUI tape player" +Sleep 3s + +Tab "Diagram" +Wait /Topology/ +Sleep 1s +Highlight@3s "orders" +Caption@3s "Highlight finds text on the screen" +Sleep 3s + +# the AI panel +F8 +Wait /Ask a question/ +Type@80ms "hello" +Sleep 1500ms +F8 +Sleep 1s +---- + +Recorded with `camel tui orders --record=demo.tape --record-size=120x32` and converted with `agg`, it plays as: + +image::jbang/camel-tui-tape-player.gif[The example tape played: captions, a highlight on the Diagram tab, and a question typed in the AI panel] + +A few tips for a tape that reads well: + +* Start with a `Wait` for something the first screen shows, as the TUI needs a moment to find the + running integrations. +* After each `Caption@<n>s`, `Sleep` for as long, or the next keys play while the caption is still + being typed. +* Pair a `Highlight` with a `Caption` that says what the viewer looks at, and highlight a key + in the footer before pressing it, such as `Highlight@2s "Shift+F8"` followed by `Shift+F8`. +* Use `Action "show-keystrokes"` at the start to show each key press at the bottom of the screen. +* Record at the size the page shows, such as `--record-size=120x32`, so text is not scaled down. + +TIP: An AI agent can write and run the tape itself: the commands for the TUI are the same as the +xref:camel-jbang-tui-ai-agents.adoc[MCP tools] of the TUI (`tui_show_caption`, `tui_locate` with +`tui_draw_shape`, `tui_navigate`, `tui_action` and `tui_set_theme`), and `tui_get_screen` tells what +text to `Wait` for. Run the tape, read the `Tape:` warnings, fix the tape, and run it again. + === Converting to GIF -Convert recordings using https://github.com/asciinema/agg[agg] (for `.cast` files) or -https://github.com/charmbracelet/vhs[VHS] (for `.tape` files): +Convert the `.cast` recording with https://github.com/asciinema/agg[agg]: [source,bash] ---- -agg recording.cast out.gif # .cast -> .gif -vhs demo.tape # .tape -> .gif +agg demo.cast demo.gif +agg --speed 1.5 --font-size 14 --last-frame-duration 3 demo.cast demo.gif ---- + +The `--speed` option plays the recording faster, and `--idle-time-limit` caps every pause at that many +seconds (5 by default), so raise it when a pause is there for the viewer to read a caption. + +A tape that only uses VHS commands can also be turned into a GIF with +https://github.com/charmbracelet/vhs[VHS] directly (`vhs demo.tape`), which runs the TUI in a +terminal of its own. The commands for the TUI, such as `Caption`, `Highlight` and `Tab`, work +with `--record` only. 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 a5ee7fda7e58..cc9122bcc532 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -182,7 +182,7 @@ See the xref:camel-jbang-tui-source-editor.adoc#_keyboard_shortcuts[keyboard sho | | `--record` -| Replay a `.tape` file and record the session to an Asciinema `.cast` file. +| Play a `.tape` file in the TUI and record it to an Asciinema `.cast` file. See xref:camel-jbang-tui-settings.adoc#_scripted_recording[Scripted Recording] for the tape commands. | | `--record-size` diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc index 36fc29d9220e..a2942f5a0dab 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui-monitor.adoc @@ -23,7 +23,7 @@ camel tui monitor [options] | Option | Description | Default | Type | `--mcp` | Enable embedded MCP server for AI agent access to the TUI | | boolean | `--mcp-port` | MCP server port | 8123 | int -| `--record` | Replay a .tape file inside the TUI and record to an Asciinema .cast file | | String +| `--record` | Play a .tape file in the TUI and record it to an Asciinema .cast file | | String | `--record-duration` | Maximum duration in milliseconds captured by --record | 120000 | int | `--record-fps` | Frames per second captured by --record | 10 | int | `--record-size` | Size of the recorded terminal for --record, as <cols>x<rows> | 200x50 | String diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc index b21f60019690..f716223e2286 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-tui.adoc @@ -32,7 +32,7 @@ camel tui [options] | Option | Description | Default | Type | `--mcp` | Enable embedded MCP server for AI agent access to the TUI | | boolean | `--mcp-port` | MCP server port | 8123 | int -| `--record` | Replay a .tape file inside the TUI and record to an Asciinema .cast file | | String +| `--record` | Play a .tape file in the TUI and record it to an Asciinema .cast file | | String | `--record-duration` | Maximum duration in milliseconds captured by --record | 120000 | int | `--record-fps` | Frames per second captured by --record | 10 | int | `--record-size` | Size of the recorded terminal for --record, as <cols>x<rows> | 200x50 | String diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json b/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json index 280ee0d05414..265a9523e427 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/camel-jbang-plugin-commands-metadata.json @@ -1,5 +1,5 @@ { "commands": [ - { "name": "tui", "fullName": "tui", "description": "Camel TUI", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.tui.TuiCommand", "plugin": "tui", "options": [ { "names": "--mcp", "description": "Enable embedded MCP server for AI agent access to the TUI", "javaType": "boolean", "type": "boolean" }, { "names": "--mcp-port", "description": "MCP server port", "defaultValue": "8123", "javaType": "int", "type": "integer" }, { "names": "--record", "description": "Replay a .tape fil [...] + { "name": "tui", "fullName": "tui", "description": "Camel TUI", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.tui.TuiCommand", "plugin": "tui", "options": [ { "names": "--mcp", "description": "Enable embedded MCP server for AI agent access to the TUI", "javaType": "boolean", "type": "boolean" }, { "names": "--mcp-port", "description": "MCP server port", "defaultValue": "8123", "javaType": "int", "type": "integer" }, { "names": "--record", "description": "Play a .tape file [...] ] } 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 19d7b8f82d71..79c02ec1ffdc 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 @@ -29,6 +29,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Queue; @@ -97,7 +98,6 @@ public class CamelMonitor extends CamelCommand { */ static final List<String> RECORD_PROPERTIES = List.of( "tamboui.record", - "tamboui.record.config", "tamboui.record.width", "tamboui.record.height", "tamboui.record.duration", @@ -118,7 +118,7 @@ public class CamelMonitor extends CamelCommand { long refreshInterval = DEFAULT_REFRESH_MS; @CommandLine.Option(names = { "--record" }, - description = "Replay a .tape file inside the TUI and record to an Asciinema .cast file", + description = "Play a .tape file in the TUI and record it to an Asciinema .cast file", arity = "0..1") String record; @@ -270,7 +270,8 @@ public class CamelMonitor extends CamelCommand { /** * Hands the {@code --record*} options to TamboUI through the {@link #RECORD_PROPERTIES} system properties, which is - * the only way TamboUI accepts a recording configuration. + * the only way TamboUI accepts a recording configuration. TamboUI only records: the tape is played by + * {@link TapePlayer} (see {@link #startTapePlayer}), which knows the keys and commands of the TUI. */ void configureRecording() { if (record == null) { @@ -288,14 +289,58 @@ public class CamelMonitor extends CamelCommand { Path tapeFile = Path.of(record); Path castFile = Path.of(record.replaceAll("\\.tape$", "") + ".cast"); int[] size = parseRecordSize(recordSize); + if (!Files.isRegularFile(tapeFile)) { + throw new CommandLine.ParameterException( + new CommandLine(this), "Tape file not found: " + tapeFile.toAbsolutePath()); + } 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)); } + /** + * Plays the {@code --record} tape on a background thread: its keys go through the same queue as the keys of an MCP + * client, its TUI commands call the MCP tools, and it quits the TUI at the end of the tape, which ends the + * recording. + */ + private void startTapePlayer(TuiRunner tui) throws IOException { + TapePlayer player = new TapePlayer(warning -> System.err.println("Tape: " + warning)); + List<TapePlayer.Step> steps = player.parse(Path.of(record)); + TuiToolRegistry tools = new TuiToolRegistry(mcpFacade); + TapePlayer.Driver driver = new TapePlayer.Driver() { + @Override + public void key(KeyEvent event) { + pendingKeys.add(new McpFacade.PendingKey(event, System.currentTimeMillis())); + } + + @Override + public String screen() { + return TapePlayer.screenText(recordingManager.getLastBuffer()); + } + + @Override + public String tool(String name, Map<String, Object> args) throws Exception { + return tools.execute(name, args); + } + + @Override + public void quit() { + tui.quit(); + } + }; + Thread thread = new Thread(() -> { + try { + player.play(steps, driver); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, "tui-tape-player"); + thread.setDaemon(true); + thread.start(); + } + /** * Undoes {@link #configureRecording()} at the end of the session that ran it. * <p> @@ -1148,6 +1193,9 @@ public class CamelMonitor extends CamelCommand { Signal.handle(new Signal("INT"), sig -> tui.quit()); } updateWindowTitle(); + if (record != null) { + startTapePlayer(tui); + } tui.run( this::handleEvent, this::render); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java new file mode 100644 index 000000000000..e47868cd47bf --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayer.java @@ -0,0 +1,462 @@ +/* + * 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 java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Plays a tape for {@code camel tui --record}: the TUI is driven by the tape while TamboUI records it to an asciinema + * {@code .cast} file. + * <p> + * A tape uses the <a href="https://github.com/charmbracelet/vhs">VHS</a> syntax (keys, {@code Type}, {@code Sleep}, + * {@code Wait /regex/}) plus commands for the TUI itself ({@code Caption}, {@code Highlight}, {@code Tab}, ...). The + * keys go through the same queue as the keys an MCP client sends, so every key works, function keys and modifiers + * included, and the TUI commands call the same tools an MCP client calls. + */ +final class TapePlayer { + + /** What the player drives: the TUI, or a test double. */ + interface Driver { + /** Presses a key. */ + void key(KeyEvent event); + + /** The screen as text, its rows separated by newlines; null before the first frame is rendered. */ + String screen(); + + /** Calls a TUI tool (as an MCP client does) and returns its result. */ + String tool(String name, Map<String, Object> args) throws Exception; + + /** Ends the TUI, which ends the recording. */ + void quit(); + } + + /** One step of a tape. */ + sealed interface Step + permits KeyStep, SleepStep, WaitStep, ToolStep, HighlightStep { + } + + record KeyStep(KeyEvent event) implements Step { + } + + record SleepStep(long millis) implements Step { + } + + /** + * Waits until a line of the screen (or the whole screen, its rows joined by newlines) matches the pattern. + */ + record WaitStep(Pattern pattern, boolean wholeScreen, long timeoutMillis, String line) implements Step { + } + + record ToolStep(String name, Map<String, Object> args, String line) implements Step { + } + + /** Marks the first place on the screen showing the text, optionally for a number of seconds. */ + record HighlightStep(String text, int seconds, String line) implements Step { + } + + /** How long typing waits between characters, and keys between presses, unless the tape says otherwise. */ + static final long DEFAULT_TYPING_MILLIS = 50; + /** How long a Wait waits for its text when the tape gives no timeout, as in VHS. */ + static final long DEFAULT_WAIT_MILLIS = 15000; + /** How often the screen is checked while waiting. */ + static final long WAIT_POLL_MILLIS = 100; + /** How long the first frame may take to render before the tape starts anyway. */ + static final long FIRST_FRAME_MILLIS = 10000; + /** How long the last frame stays before the TUI quits, so the recording shows it. */ + static final long END_PAUSE_MILLIS = 1000; + + private final Consumer<String> warnings; + + TapePlayer(Consumer<String> warnings) { + this.warnings = warnings; + } + + // ---- Parsing ---- + + /** Parses a tape file; {@code Source} includes other tapes relative to it. */ + List<Step> parse(Path tape) throws IOException { + List<Step> steps = new ArrayList<>(); + parse(Files.readAllLines(tape), tape.toAbsolutePath().getParent(), steps); + return steps; + } + + /** Parses the lines of a tape into steps; unknown lines are reported and skipped. */ + List<Step> parse(List<String> lines) { + List<Step> steps = new ArrayList<>(); + parse(lines, null, steps); + return steps; + } + + private void parse(List<String> lines, Path dir, List<Step> steps) { + for (String raw : lines) { + String line = raw.strip(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + try { + parseLine(line, dir, steps); + } catch (IllegalArgumentException e) { + warnings.accept(e.getMessage() + ": " + line); + } + } + } + + private void parseLine(String line, Path dir, List<Step> steps) { + // The command is the first word, optionally with a timing: Command@duration ("Down@200ms 3", "Type@100ms + // "text"", "Wait@10s /Ready/", "Caption@5s "text""). Only the first word is checked for '@', so text such as + // Type "[email protected]" is typed as it is. + String[] parts = line.split("\\s+", 2); + String head = parts[0]; + String args = parts.length > 1 ? parts[1].strip() : ""; + long timing = -1; + int at = head.indexOf('@'); + if (at > 0) { + timing = parseDuration(head.substring(at + 1)); + head = head.substring(0, at); + } + String command = head.toLowerCase(Locale.ROOT); + + switch (command) { + // directives for the vhs tool (terminal settings, output file, required programs): the recording takes + // its size and file from the --record options + case "set", "output", "require" -> { + } + case "source" -> { + Path file = dir != null ? dir.resolve(args) : Path.of(args); + try { + parse(Files.readAllLines(file), file.toAbsolutePath().getParent(), steps); + } catch (IOException e) { + throw new IllegalArgumentException("Cannot read the tape to include (" + e.getMessage() + ")"); + } + } + case "hide", "show" -> throw new IllegalArgumentException( + "Hide and Show are not supported, the interactions are recorded"); + case "sleep" -> steps.add(new SleepStep(parseDuration(args))); + case "type" -> addTyped(unquote(args), timing >= 0 ? timing : DEFAULT_TYPING_MILLIS, steps); + case "wait", "wait+line", "wait+screen" -> steps.add(new WaitStep( + parseRegex(args), "wait+screen".equals(command), timing > 0 ? timing : DEFAULT_WAIT_MILLIS, line)); + case "screenshot" -> steps.add(new ToolStep("tui_action", Map.of("action", "screenshot"), line)); + // the commands for the TUI itself + case "caption" -> { + Map<String, Object> toolArgs = new LinkedHashMap<>(); + toolArgs.put("text", unquote(args).replace("\\n", "\n")); + if (timing > 0) { + toolArgs.put("duration", (int) Math.max(1, timing / 1000)); + } + steps.add(new ToolStep("tui_show_caption", toolArgs, line)); + } + case "highlight" -> steps.add(new HighlightStep(unquote(args), timing > 0 ? (int) (timing / 1000) : 0, line)); + case "cleardrawing" -> steps.add(new ToolStep("tui_draw_clear", Map.of(), line)); + case "tab" -> steps.add(new ToolStep("tui_navigate", Map.of("tab", unquote(args)), line)); + case "integration" -> steps.add(new ToolStep("tui_navigate", Map.of("integration", unquote(args)), line)); + case "action" -> steps.add(new ToolStep("tui_action", Map.of("action", unquote(args)), line)); + case "theme" -> steps.add(new ToolStep("tui_set_theme", Map.of("theme", unquote(args)), line)); + default -> { + KeyEvent key = parseKey(head); + if (key == null) { + throw new IllegalArgumentException("Unknown tape command"); + } + int count = args.isEmpty() ? 1 : parseCount(args); + long gap = timing >= 0 ? timing : DEFAULT_TYPING_MILLIS; + for (int i = 0; i < count; i++) { + steps.add(new KeyStep(key)); + steps.add(new SleepStep(gap)); + } + } + } + } + + private static void addTyped(String text, long gap, List<Step> steps) { + for (int i = 0; i < text.length();) { + int cp = text.codePointAt(i); + i += Character.charCount(cp); + KeyEvent key = switch (cp) { + case '\n', '\r' -> KeyEvent.ofKey(KeyCode.ENTER); + case '\t' -> KeyEvent.ofKey(KeyCode.TAB); + default -> KeyEvent.ofChar(cp); + }; + steps.add(new KeyStep(key)); + steps.add(new SleepStep(gap)); + } + } + + /** + * A key name with optional modifiers ({@code Enter}, {@code F8}, {@code Shift+F8}, {@code Ctrl+c}, {@code Alt+x}, + * {@code Shift+Tab}); null when it is not a key. + */ + static KeyEvent parseKey(String spec) { + boolean ctrl = false; + boolean alt = false; + boolean shift = false; + String key = spec; + int plus; + // a lone "+" is a key of its own, not a modifier separator + while ((plus = key.indexOf('+')) > 0 && plus < key.length() - 1) { + switch (key.substring(0, plus).toLowerCase(Locale.ROOT)) { + case "ctrl", "control" -> ctrl = true; + case "alt", "meta" -> alt = true; + case "shift" -> shift = true; + default -> { + return null; + } + } + key = key.substring(plus + 1); + } + boolean modified = ctrl || alt || shift; + KeyModifiers modifiers = KeyModifiers.of(ctrl, alt, shift); + KeyCode code = switch (key.toLowerCase(Locale.ROOT)) { + case "enter", "return" -> KeyCode.ENTER; + case "tab" -> KeyCode.TAB; + case "backspace" -> KeyCode.BACKSPACE; + case "delete" -> KeyCode.DELETE; + case "insert" -> KeyCode.INSERT; + case "escape", "esc" -> KeyCode.ESCAPE; + case "up" -> KeyCode.UP; + case "down" -> KeyCode.DOWN; + case "left" -> KeyCode.LEFT; + case "right" -> KeyCode.RIGHT; + case "home" -> KeyCode.HOME; + case "end" -> KeyCode.END; + case "pageup", "pgup" -> KeyCode.PAGE_UP; + case "pagedown", "pgdn" -> KeyCode.PAGE_DOWN; + default -> functionKey(key); + }; + if (code != null) { + return KeyEvent.ofKey(code, modifiers); + } + if ("space".equalsIgnoreCase(key)) { + return KeyEvent.ofChar(' ', modifiers); + } + // a single character is a key only with a modifier (Ctrl+t); a bare letter on its own line is a typo + if (modified && key.codePointCount(0, key.length()) == 1) { + int cp = key.codePointAt(0); + return KeyEvent.ofChar(shift && Character.isLetter(cp) ? Character.toUpperCase(cp) : cp, modifiers); + } + return null; + } + + private static KeyCode functionKey(String key) { + if (key.length() >= 2 && key.length() <= 3 && (key.charAt(0) == 'F' || key.charAt(0) == 'f')) { + try { + int n = Integer.parseInt(key.substring(1)); + if (n >= 1 && n <= 12) { + return KeyCode.valueOf("F" + n); + } + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + /** A duration such as {@code 500ms}, {@code 2s} or {@code 1.5s}; a bare number is seconds, as in VHS. */ + static long parseDuration(String text) { + String s = text.strip().toLowerCase(Locale.ROOT); + try { + if (s.endsWith("ms")) { + return Long.parseLong(s.substring(0, s.length() - 2).strip()); + } + if (s.endsWith("s")) { + return Math.round(Double.parseDouble(s.substring(0, s.length() - 1).strip()) * 1000); + } + return Math.round(Double.parseDouble(s) * 1000); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid duration '" + text + "'"); + } + } + + private static int parseCount(String text) { + try { + return Math.max(1, Integer.parseInt(text.strip())); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid repeat count '" + text + "'"); + } + } + + private static Pattern parseRegex(String text) { + if (text.length() < 2 || !text.startsWith("/") || !text.endsWith("/")) { + throw new IllegalArgumentException("Wait needs a /regex/ to wait for"); + } + try { + return Pattern.compile(text.substring(1, text.length() - 1)); + } catch (PatternSyntaxException e) { + throw new IllegalArgumentException("Invalid regex (" + e.getDescription() + ")"); + } + } + + /** The text of a quoted argument ("..." with \" \\ \n \t escapes), or the argument as it is when unquoted. */ + static String unquote(String text) { + String s = text.strip(); + if (s.length() < 2 || s.charAt(0) != '"' || s.charAt(s.length() - 1) != '"') { + return s; + } + StringBuilder sb = new StringBuilder(); + for (int i = 1; i < s.length() - 1; i++) { + char c = s.charAt(i); + if (c == '\\' && i + 1 < s.length() - 1) { + char next = s.charAt(++i); + switch (next) { + case 'n' -> sb.append('\n'); + case 't' -> sb.append('\t'); + case 'r' -> sb.append('\r'); + default -> sb.append(next); + } + } else { + sb.append(c); + } + } + return sb.toString(); + } + + // ---- Playing ---- + + /** Plays the steps on the calling thread, then quits the TUI. */ + void play(List<Step> steps, Driver driver) throws InterruptedException { + long start = System.currentTimeMillis(); + while (driver.screen() == null && System.currentTimeMillis() - start < FIRST_FRAME_MILLIS) { + Thread.sleep(WAIT_POLL_MILLIS); + } + for (Step step : steps) { + if (step instanceof KeyStep k) { + driver.key(k.event()); + } else if (step instanceof SleepStep s) { + Thread.sleep(s.millis()); + } else if (step instanceof WaitStep w) { + waitFor(w, driver); + } else if (step instanceof ToolStep t) { + callTool(t.name(), t.args(), t.line(), driver); + } else if (step instanceof HighlightStep h) { + highlight(h, driver); + } + } + Thread.sleep(END_PAUSE_MILLIS); + driver.quit(); + } + + private void waitFor(WaitStep wait, Driver driver) throws InterruptedException { + long deadline = System.currentTimeMillis() + wait.timeoutMillis(); + while (!matches(wait, driver.screen())) { + if (System.currentTimeMillis() >= deadline) { + warnings.accept("Timed out after " + wait.timeoutMillis() + "ms: " + wait.line()); + return; + } + Thread.sleep(WAIT_POLL_MILLIS); + } + } + + static boolean matches(WaitStep wait, String screen) { + if (screen == null) { + return false; + } + if (wait.wholeScreen()) { + return wait.pattern().matcher(screen).find(); + } + for (String row : screen.split("\n", -1)) { + if (wait.pattern().matcher(row).find()) { + return true; + } + } + return false; + } + + private String callTool(String name, Map<String, Object> args, String line, Driver driver) { + try { + String result = driver.tool(name, args); + if (result != null && result.startsWith("Error")) { + warnings.accept(result + ": " + line); + } + return result; + } catch (Exception e) { + warnings.accept("Failed (" + e.getMessage() + "): " + line); + return null; + } + } + + private void highlight(HighlightStep step, Driver driver) { + String located = callTool("tui_locate", Map.of("text", step.text()), step.line(), driver); + JsonObject match = firstMatch(located); + if (match == null) { + warnings.accept("Text not on the screen: " + step.line()); + return; + } + Map<String, Object> args = new LinkedHashMap<>(); + args.put("shape", "highlight"); + args.put("x", match.getInteger("x")); + args.put("y", match.getInteger("y")); + args.put("width", match.getInteger("width")); + args.put("append", true); + if (step.seconds() > 0) { + args.put("duration", step.seconds()); + } + callTool("tui_draw_shape", args, step.line(), driver); + } + + private static JsonObject firstMatch(String located) { + if (located == null) { + return null; + } + try { + Object parsed = Jsoner.deserialize(located); + if (parsed instanceof JsonObject result && result.get("matches") instanceof JsonArray matches + && !matches.isEmpty() && matches.get(0) instanceof JsonObject first) { + return first; + } + } catch (Exception e) { + // not a locate result + } + return null; + } + + /** The buffer as text, its rows separated by newlines (continuation cells of wide characters left out). */ + static String screenText(Buffer buffer) { + if (buffer == null) { + return null; + } + Rect area = buffer.area(); + StringBuilder sb = new StringBuilder(); + for (int y = area.top(); y < area.bottom(); y++) { + if (y > area.top()) { + sb.append('\n'); + } + for (int x = area.left(); x < area.right(); x++) { + if (!buffer.get(x, y).isContinuation()) { + sb.append(buffer.get(x, y).symbol()); + } + } + } + return sb.toString(); + } +} 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 fdbc5c91e531..a022f5138711 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 @@ -55,7 +55,7 @@ public class TuiCommand extends CamelCommand { long refreshInterval = 100; @CommandLine.Option(names = { "--record" }, - description = "Replay a .tape file inside the TUI and record to an Asciinema .cast file", + description = "Play a .tape file in the TUI and record it to an Asciinema .cast file", arity = "0..1") String record; 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 9b47a877c040..bd6400565db2 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 @@ -16,8 +16,13 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junitpioneer.jupiter.ClearSystemProperty; import picocli.CommandLine; @@ -43,6 +48,9 @@ class CamelMonitorRecordOptionsTest { private final CamelMonitor monitor = new CamelMonitor(new CamelJBangMain(), getClass().getClassLoader()); + @TempDir + Path tempDir; + @Test void parsesColumnsAndRows() { assertThat(monitor.parseRecordSize("160x44")).containsExactly(160, 44); @@ -93,10 +101,10 @@ class CamelMonitorRecordOptionsTest { } @Test - void configuringRecordingSetsEveryPropertyThatIsClearedAgainAfterwards() { + void configuringRecordingSetsEveryPropertyThatIsClearedAgainAfterwards() throws IOException { // 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.record = tape().toString(); monitor.recordSize = "160x44"; monitor.configureRecording(); @@ -119,4 +127,30 @@ class CamelMonitorRecordOptionsTest { assertThat(CamelMonitor.RECORD_PROPERTIES) .allSatisfy(key -> assertThat(System.getProperty(key)).as(key).isNull()); } + + @Test + void doesNotHandTheTapeToTamboui() throws IOException { + // TamboUI only records; TapePlayer plays the tape (TamboUI's own player has no function keys) + monitor.record = tape().toString(); + + monitor.configureRecording(); + + assertThat(System.getProperty("tamboui.record")).endsWith("demo.cast"); + assertThat(System.getProperty("tamboui.record.config")).isNull(); + } + + @Test + void rejectsAMissingTapeFile() { + monitor.record = tempDir.resolve("missing.tape").toString(); + + assertThatThrownBy(monitor::configureRecording) + .isInstanceOf(CommandLine.ParameterException.class) + .hasMessageContaining("Tape file not found"); + } + + private Path tape() throws IOException { + Path tape = tempDir.resolve("demo.tape"); + Files.writeString(tape, "Sleep 100ms\n"); + return tape; + } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayerTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayerTest.java new file mode 100644 index 000000000000..12a926607387 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TapePlayerTest.java @@ -0,0 +1,237 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Style; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TapePlayerTest { + + private final List<String> warnings = new ArrayList<>(); + private final TapePlayer player = new TapePlayer(warnings::add); + + @Test + void parsesKeysWithModifiers() { + assertThat(TapePlayer.parseKey("F8").code()).isEqualTo(KeyCode.F8); + KeyEvent shiftF8 = TapePlayer.parseKey("Shift+F8"); + assertThat(shiftF8.code()).isEqualTo(KeyCode.F8); + assertThat(shiftF8.hasShift()).isTrue(); + KeyEvent ctrlT = TapePlayer.parseKey("Ctrl+t"); + assertThat(ctrlT.isChar('t')).isTrue(); + assertThat(ctrlT.hasCtrl()).isTrue(); + assertThat(TapePlayer.parseKey("Alt+x").hasAlt()).isTrue(); + assertThat(TapePlayer.parseKey("Shift+Tab").code()).isEqualTo(KeyCode.TAB); + assertThat(TapePlayer.parseKey("Escape").code()).isEqualTo(KeyCode.ESCAPE); + assertThat(TapePlayer.parseKey("PageDown").code()).isEqualTo(KeyCode.PAGE_DOWN); + } + + @Test + void doesNotTakeABareLetterOrAnUnknownNameForAKey() { + assertThat(TapePlayer.parseKey("q")).isNull(); + assertThat(TapePlayer.parseKey("F13")).isNull(); + assertThat(TapePlayer.parseKey("Hyper+x")).isNull(); + } + + @Test + void repeatsAKeyWithACount() { + List<TapePlayer.Step> steps = player.parse(List.of("Down 3")); + + assertThat(keys(steps)).extracting(KeyEvent::code).containsExactly(KeyCode.DOWN, KeyCode.DOWN, KeyCode.DOWN); + } + + @Test + void typesTextWithItsSpeedAndKeepsAtSigns() { + List<TapePlayer.Step> steps = player.parse(List.of("Type@100ms \"a@b\"")); + + assertThat(keys(steps)).extracting(KeyEvent::string).containsExactly("a", "@", "b"); + assertThat(steps).filteredOn(s -> s instanceof TapePlayer.SleepStep) + .extracting(s -> ((TapePlayer.SleepStep) s).millis()) + .containsOnly(100L); + } + + @Test + void parsesDurationsAsInVhs() { + assertThat(TapePlayer.parseDuration("500ms")).isEqualTo(500); + assertThat(TapePlayer.parseDuration("2s")).isEqualTo(2000); + assertThat(TapePlayer.parseDuration("1.5s")).isEqualTo(1500); + assertThat(TapePlayer.parseDuration("3")).isEqualTo(3000); + } + + @Test + void parsesWaitWithItsTimeout() { + TapePlayer.WaitStep wait = (TapePlayer.WaitStep) player.parse(List.of("Wait+Screen@2s /Ready/")).get(0); + + assertThat(wait.pattern().pattern()).isEqualTo("Ready"); + assertThat(wait.wholeScreen()).isTrue(); + assertThat(wait.timeoutMillis()).isEqualTo(2000); + } + + @Test + void parsesTheTuiCommands() { + List<TapePlayer.Step> steps = player.parse(List.of( + "Caption@5s \"Fix with AI\\nShift+F8\"", + "Tab \"Errors\"", + "Integration orders", + "Action \"show-keystrokes\"", + "Theme \"dracula\"", + "ClearDrawing")); + + assertThat(steps).hasSize(6).allMatch(s -> s instanceof TapePlayer.ToolStep); + TapePlayer.ToolStep caption = (TapePlayer.ToolStep) steps.get(0); + assertThat(caption.name()).isEqualTo("tui_show_caption"); + assertThat(caption.args()).containsEntry("text", "Fix with AI\nShift+F8").containsEntry("duration", 5); + assertThat(((TapePlayer.ToolStep) steps.get(1)).args()).containsEntry("tab", "Errors"); + assertThat(((TapePlayer.ToolStep) steps.get(2)).args()).containsEntry("integration", "orders"); + assertThat(((TapePlayer.ToolStep) steps.get(3)).name()).isEqualTo("tui_action"); + assertThat(((TapePlayer.ToolStep) steps.get(4)).name()).isEqualTo("tui_set_theme"); + assertThat(((TapePlayer.ToolStep) steps.get(5)).name()).isEqualTo("tui_draw_clear"); + assertThat(warnings).isEmpty(); + } + + @Test + void reportsAndSkipsWhatItDoesNotKnow() { + List<TapePlayer.Step> steps = player.parse(List.of( + "Set FontSize 32", "Output demo.gif", "Frobnicate", "q", "Hide", "Wait /[unclosed/", "Enter")); + + assertThat(keys(steps)).extracting(KeyEvent::code).containsExactly(KeyCode.ENTER); + assertThat(warnings).hasSize(4); + assertThat(warnings.get(0)).contains("Unknown tape command: Frobnicate"); + assertThat(warnings.get(1)).contains("Unknown tape command: q"); + assertThat(warnings.get(2)).contains("Hide and Show are not supported"); + assertThat(warnings.get(3)).contains("Invalid regex"); + } + + @Test + void waitMatchesALineOrTheWholeScreen() { + String screen = "Loading\nReady "; + TapePlayer.WaitStep line = new TapePlayer.WaitStep(Pattern.compile("Loading\\s*\\nReady"), false, 0, ""); + TapePlayer.WaitStep whole = new TapePlayer.WaitStep(Pattern.compile("Loading\\s*\\nReady"), true, 0, ""); + + assertThat(TapePlayer.matches(line, screen)).isFalse(); + assertThat(TapePlayer.matches(whole, screen)).isTrue(); + assertThat(TapePlayer.matches(whole, null)).isFalse(); + } + + @Test + void playsKeysWaitsForTheScreenHighlightsAndQuits() throws Exception { + AtomicReference<String> screen = new AtomicReference<>("Loading"); + List<KeyEvent> pressed = new ArrayList<>(); + List<String> tools = new ArrayList<>(); + AtomicBoolean quit = new AtomicBoolean(); + TapePlayer.Driver driver = new TapePlayer.Driver() { + @Override + public void key(KeyEvent event) { + pressed.add(event); + if (event.code() == KeyCode.F5) { + screen.set("Errors\n Shift+F8 fix with AI"); + } + } + + @Override + public String screen() { + return screen.get(); + } + + @Override + public String tool(String name, Map<String, Object> args) { + tools.add(name + " " + args); + if ("tui_locate".equals(name)) { + return "{\"matches\":[{\"x\":1,\"y\":1,\"width\":8,\"height\":1,\"text\":\"Shift+F8\"}]}"; + } + return "ok"; + } + + @Override + public void quit() { + quit.set(true); + } + }; + + player.play(player.parse(List.of( + "F5", + "Wait@2s /fix with AI/", + "Highlight \"Shift+F8\"", + "Shift+F8")), driver); + + assertThat(pressed).extracting(KeyEvent::code).containsExactly(KeyCode.F5, KeyCode.F8); + assertThat(pressed.get(1).hasShift()).isTrue(); + assertThat(tools).hasSize(2); + assertThat(tools.get(0)).startsWith("tui_locate"); + assertThat(tools.get(1)).startsWith("tui_draw_shape").contains("x=1").contains("y=1").contains("width=8"); + assertThat(quit).isTrue(); + assertThat(warnings).isEmpty(); + } + + @Test + void reportsAWaitThatTimesOut() throws Exception { + TapePlayer.Driver driver = new TapePlayer.Driver() { + @Override + public void key(KeyEvent event) { + } + + @Override + public String screen() { + return "Loading"; + } + + @Override + public String tool(String name, Map<String, Object> args) { + return "ok"; + } + + @Override + public void quit() { + } + }; + + player.play(player.parse(List.of("Wait@200ms /Ready/")), driver); + + assertThat(warnings).singleElement().asString().startsWith("Timed out after 200ms"); + } + + @Test + void screenTextLeavesOutTheContinuationOfWideCharacters() { + Buffer buffer = Buffer.empty(new Rect(0, 0, 4, 2)); + buffer.setString(0, 0, "世a", Style.EMPTY); + buffer.setString(0, 1, "ok", Style.EMPTY); + + assertThat(TapePlayer.screenText(buffer)).isEqualTo("世a \nok "); + } + + private static List<KeyEvent> keys(List<TapePlayer.Step> steps) { + List<KeyEvent> keys = new ArrayList<>(); + for (TapePlayer.Step step : steps) { + if (step instanceof TapePlayer.KeyStep k) { + keys.add(k.event()); + } + } + return keys; + } +}
