diff --git a/.gitignore b/.gitignore index 9a87efd1..be65972e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target node_modules .cargo +.venv-mcp/ compile_commands.json _* !crates/ros-z-py/python/**/__init__.py @@ -61,3 +62,9 @@ core # Cached nix dev shell environment (generated by sandbox.nu) .state/ + +# Demo video output (generated by scripts/demos/generate.nu) +scripts/demos/results/ + +# TUI test output (generated by tests/run-vhs-tests.nu) +tests/results/ diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 9dcacaa0..b345b3c4 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -31,6 +31,7 @@ # Tools - [ros-z-console](./chapters/console.md) + - [TUI Demos](./chapters/console-demos.md) # Language Bindings diff --git a/book/src/chapters/console-demos.md b/book/src/chapters/console-demos.md new file mode 100644 index 00000000..a3abf771 --- /dev/null +++ b/book/src/chapters/console-demos.md @@ -0,0 +1,82 @@ +# ros-z-console TUI Demos + +These demos use the classic `z_pubsub` (talker/listener) and `z_srvcli` (AddTwoInts server) +examples as a live ROS 2 system. + +**Setup:** + +```bash +# Terminal 1 +zenohd + +# Terminal 2 +ros-z-console tcp/127.0.0.1:7447 0 +``` + +--- + +## Startup + +ros-z-console connects to the Zenoh router and discovers all live entities in the graph. + +![ros-z-console connecting to a ROS 2 system and discovering topics and nodes](../img/01-startup.gif) + +--- + +## Topics Panel + +The Topics panel (default) lists all active ROS 2 topics with their type and publisher/subscriber counts. +Navigate with `j` / `k` (or arrow keys). Press `l` or `Enter` to open the detail pane. + +![Browsing the topics list in ros-z-console](../img/02-navigation.gif) + +--- + +## Topic Detail + +Select a topic and press `l` or `Enter` to view publishers, subscribers, type hash, and QoS profiles. + +![Topic detail view showing publishers, subscribers and QoS](../img/05-topic-detail.gif) + +--- + +## Services Panel + +Press `2` (or `Tab`) to switch to the Services panel. Lists all active ROS 2 services with their type. + +![Services panel listing active ROS 2 services](../img/06-services.gif) + +--- + +## Nodes Panel + +Press `3` to switch to the Nodes panel. Lists all active nodes. Select a node and press `l` or `Enter` +to see its publishers, subscribers, and services. + +![Nodes panel with per-node topic and service associations](../img/07-nodes.gif) + +--- + +## Rate Measurement + +Press `r` on a selected topic for a quick rate check (cached 30s). Switch to the Measure panel +(`4` or `m`) for a continuous measurement with a 60-second time-series chart. + +![Rate measurement and time-series chart in ros-z-console](../img/08-measurement.gif) + +--- + +## Filter Mode + +Press `/` to enter filter mode and start typing. The list narrows to matching items in real time. +Press `Ctrl+U` to clear, `Escape` to exit filter mode. + +![Filter mode with type-ahead search in ros-z-console](../img/03-filter.gif) + +--- + +## Help Overlay + +Press `?` to toggle the help overlay showing all keybindings. + +![Help overlay showing all keybindings in ros-z-console](../img/04-help.gif) diff --git a/book/src/chapters/console.md b/book/src/chapters/console.md index 06b577cf..302b5775 100644 --- a/book/src/chapters/console.md +++ b/book/src/chapters/console.md @@ -165,6 +165,8 @@ The interactive terminal interface provides: TUI mode requires a terminal that supports ANSI escape codes. Most modern terminals work out of the box. ``` +See [TUI Demos](./console-demos.md) for annotated walkthroughs of each workflow. + ### Headless Mode Headless mode streams events to stdout, making it ideal for: diff --git a/book/src/img/01-startup.gif b/book/src/img/01-startup.gif new file mode 100644 index 00000000..a2af606d Binary files /dev/null and b/book/src/img/01-startup.gif differ diff --git a/book/src/img/02-navigation.gif b/book/src/img/02-navigation.gif new file mode 100644 index 00000000..db0590f1 Binary files /dev/null and b/book/src/img/02-navigation.gif differ diff --git a/book/src/img/03-filter.gif b/book/src/img/03-filter.gif new file mode 100644 index 00000000..4cc9ad8a Binary files /dev/null and b/book/src/img/03-filter.gif differ diff --git a/book/src/img/04-help.gif b/book/src/img/04-help.gif new file mode 100644 index 00000000..85f26849 Binary files /dev/null and b/book/src/img/04-help.gif differ diff --git a/book/src/img/05-topic-detail.gif b/book/src/img/05-topic-detail.gif new file mode 100644 index 00000000..06f31c4f Binary files /dev/null and b/book/src/img/05-topic-detail.gif differ diff --git a/book/src/img/06-services.gif b/book/src/img/06-services.gif new file mode 100644 index 00000000..e428504a Binary files /dev/null and b/book/src/img/06-services.gif differ diff --git a/book/src/img/07-nodes.gif b/book/src/img/07-nodes.gif new file mode 100644 index 00000000..6c707d2f Binary files /dev/null and b/book/src/img/07-nodes.gif differ diff --git a/book/src/img/08-measurement.gif b/book/src/img/08-measurement.gif new file mode 100644 index 00000000..341b35aa Binary files /dev/null and b/book/src/img/08-measurement.gif differ diff --git a/crates/ros-z-console/src/app/render/nodes.rs b/crates/ros-z-console/src/app/render/nodes.rs index 46a1afd1..c0e9a201 100644 --- a/crates/ros-z-console/src/app/render/nodes.rs +++ b/crates/ros-z-console/src/app/render/nodes.rs @@ -30,7 +30,12 @@ impl App { let style = list_item_style(i == self.selected_index); // Format: "* /" - let full_name = format!("{}/{}", namespace, name); + // When namespace is "/" (root), avoid producing "//name" + let full_name = if namespace == "/" { + format!("/{}", name) + } else { + format!("{}/{}", namespace, name) + }; let icon_width = 2; // "* " let node_max_width = list_width.saturating_sub(icon_width); let display_node = truncate_with_ellipsis(&full_name, node_max_width); @@ -68,14 +73,26 @@ impl App { return "No node selected".to_string(); }; - let node_key = (namespace.clone(), name.clone()); + // Normalize namespace for HashMap lookup: graph stores root nodes under "" not "/" + let normalized_ns = if namespace == "/" { + String::new() + } else { + namespace.clone() + }; + let node_key = (normalized_ns, name.clone()); let publishers = graph.get_names_and_types_by_node(node_key.clone(), EntityKind::Publisher); let subscribers = graph.get_names_and_types_by_node(node_key.clone(), EntityKind::Subscription); let services = graph.get_names_and_types_by_node(node_key.clone(), EntityKind::Service); let clients = graph.get_names_and_types_by_node(node_key, EntityKind::Client); - let mut detail = format!("Node: {}/{}\n", namespace, name); + // Display format: avoid "//name" when namespace is "/" + let display_path = if namespace == "/" { + format!("/{}", name) + } else { + format!("{}/{}", namespace, name) + }; + let mut detail = format!("Node: {}\n", display_path); let show_types = self.detail_state.publishers_expanded; let expand_hint = if show_types { "[-]" } else { "[+]" }; diff --git a/crates/ros-z-console/src/core/engine.rs b/crates/ros-z-console/src/core/engine.rs index af8977a5..f12ac8d0 100644 --- a/crates/ros-z-console/src/core/engine.rs +++ b/crates/ros-z-console/src/core/engine.rs @@ -8,12 +8,12 @@ use std::{ }; use parking_lot::Mutex; -use ros_z::{Builder, context::ZContext, graph::Graph, node::ZNode}; +use ros_z::{ + Builder, context::ZContext, dynamic::DynamicTopicSubscriber, graph::Graph, node::ZNode, +}; use tokio::sync::broadcast; -use super::{ - dynamic_subscriber::DynamicTopicSubscriber, events::SystemEvent, metrics::MetricsCollector, -}; +use super::{events::SystemEvent, metrics::MetricsCollector}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Backend { diff --git a/crates/ros-z-console/src/core/mod.rs b/crates/ros-z-console/src/core/mod.rs index 585ee335..ae0204cd 100644 --- a/crates/ros-z-console/src/core/mod.rs +++ b/crates/ros-z-console/src/core/mod.rs @@ -1,6 +1,4 @@ -pub mod dynamic_subscriber; pub mod engine; pub mod events; pub mod logger; -pub mod message_formatter; pub mod metrics; diff --git a/crates/ros-z-console/src/main.rs b/crates/ros-z-console/src/main.rs index 50674865..a52da551 100644 --- a/crates/ros-z-console/src/main.rs +++ b/crates/ros-z-console/src/main.rs @@ -12,7 +12,9 @@ use app::{ }; use clap::{Parser, ValueEnum}; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, + }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; @@ -158,9 +160,10 @@ async fn run_tui_loop( return Ok(()); } - // Poll for events with timeout + // Poll for events with timeout (ignore Release events to prevent double-dispatch) if event::poll(Duration::from_millis(POLL_TIMEOUT_MS))? && let Event::Key(key) = event::read()? + && key.kind != KeyEventKind::Release { handle_key_event(app, key).await?; } diff --git a/crates/ros-z-console/src/modes/headless.rs b/crates/ros-z-console/src/modes/headless.rs index 7e3377f1..3c016843 100644 --- a/crates/ros-z-console/src/modes/headless.rs +++ b/crates/ros-z-console/src/modes/headless.rs @@ -2,11 +2,9 @@ use chrono::Utc; use serde::Serialize; use std::{collections::HashMap, time::Duration}; -use crate::core::{ - dynamic_subscriber::DynamicTopicSubscriber, - engine::CoreEngine, - message_formatter::{dynamic_message_to_json, format_message_pretty}, -}; +use ros_z::dynamic::{DynamicTopicSubscriber, dynamic_message_to_json, format_message_pretty}; + +use crate::core::engine::CoreEngine; use ros_z::graph::GraphSnapshot; /// Wrapper for initial state that adds the event field diff --git a/crates/ros-z-console/src/core/message_formatter.rs b/crates/ros-z/src/dynamic/formatter.rs similarity index 98% rename from crates/ros-z-console/src/core/message_formatter.rs rename to crates/ros-z/src/dynamic/formatter.rs index 896636bc..22a29dac 100644 --- a/crates/ros-z-console/src/core/message_formatter.rs +++ b/crates/ros-z/src/dynamic/formatter.rs @@ -2,9 +2,10 @@ //! //! Provides JSON and human-readable text formatting for dynamic messages. -use ros_z::dynamic::{DynamicMessage, DynamicValue}; use serde_json; +use crate::dynamic::{DynamicMessage, DynamicValue}; + /// Convert a DynamicMessage to a JSON value /// /// Recursively converts all fields, handling nested messages and arrays. @@ -137,7 +138,7 @@ fn format_value_pretty(output: &mut String, name: &str, value: &DynamicValue, in #[cfg(test)] mod tests { use super::*; - use ros_z::dynamic::{FieldType, MessageSchema}; + use crate::dynamic::{FieldType, MessageSchema}; #[test] fn test_json_primitives() { diff --git a/crates/ros-z/src/dynamic/mod.rs b/crates/ros-z/src/dynamic/mod.rs index 2e028604..fc782fa5 100644 --- a/crates/ros-z/src/dynamic/mod.rs +++ b/crates/ros-z/src/dynamic/mod.rs @@ -55,11 +55,13 @@ //! ``` pub mod error; +pub mod formatter; pub mod message; pub mod registry; pub mod schema; pub mod serdes; pub mod serialization; +pub mod subscriber; pub mod type_description; pub mod type_description_client; pub mod type_description_service; @@ -70,11 +72,13 @@ mod tests; // Re-export main types pub use error::DynamicError; +pub use formatter::{dynamic_message_to_json, dynamic_value_to_json, format_message_pretty}; pub use message::{DynamicMessage, DynamicMessageBuilder}; pub use registry::{SchemaRegistry, get_schema, has_schema, register_schema}; pub use schema::{FieldSchema, FieldType, MessageSchema, MessageSchemaBuilder}; pub use serdes::DynamicSerdeCdrSerdes; pub use serialization::SerializationFormat; +pub use subscriber::DynamicTopicSubscriber; pub use type_description::{MessageSchemaTypeDescription, type_description_msg_to_schema}; pub use type_description_client::TypeDescriptionClient; pub use type_description_service::{ diff --git a/crates/ros-z-console/src/core/dynamic_subscriber.rs b/crates/ros-z/src/dynamic/subscriber.rs similarity index 57% rename from crates/ros-z-console/src/core/dynamic_subscriber.rs rename to crates/ros-z/src/dynamic/subscriber.rs index 4d5a55e9..22b9f579 100644 --- a/crates/ros-z-console/src/core/dynamic_subscriber.rs +++ b/crates/ros-z/src/dynamic/subscriber.rs @@ -1,19 +1,23 @@ -//! Dynamic topic subscriber for any ROS message type +//! Async dynamic topic subscriber //! -//! Provides automatic schema discovery and message reception for topics -//! without compile-time knowledge of message types. +//! Wraps a blocking [`ZSub`] with a flume channel so callers can poll +//! messages from async contexts without blocking the executor. use std::{sync::Arc, time::Duration}; use flume::Receiver; -use ros_z::{ - dynamic::{DynamicMessage, DynamicSerdeCdrSerdes, MessageSchema}, +use zenoh::sample::Sample; + +use crate::{ + dynamic::{DynamicCdrSerdes, DynamicMessage, MessageSchema, MessageSchemaTypeDescription}, node::ZNode, pubsub::ZSub, }; -use zenoh::sample::Sample; -/// Manages subscription to a single topic with dynamic message types +/// Manages subscription to a single topic with dynamic message types. +/// +/// Automatically discovers the message schema via the type description service, +/// then bridges the blocking [`ZSub`] to a [`flume`] channel for use in async code. pub struct DynamicTopicSubscriber { /// Topic name being subscribed to #[allow(dead_code)] @@ -25,43 +29,36 @@ pub struct DynamicTopicSubscriber { /// Channel for receiving messages asynchronously message_rx: Receiver, /// Subscriber handle (kept alive to maintain subscription) - _subscriber: Arc>, + _subscriber: Arc>, } impl DynamicTopicSubscriber { - /// Create a new dynamic subscriber with automatic schema discovery + /// Create a new dynamic subscriber with automatic schema discovery. /// /// # Arguments /// /// * `node` - ROS node with type description service enabled - /// * `topic` - Topic name to subscribe to (e.g., "/chatter") + /// * `topic` - Topic name to subscribe to (e.g., `/chatter`) /// * `discovery_timeout` - Maximum time to wait for schema discovery /// /// # Errors /// - /// Returns error if: - /// - No publishers found on topic within timeout - /// - Schema discovery fails - /// - Subscriber creation fails + /// Returns an error if no publishers are found within the timeout or if + /// schema discovery fails. pub async fn new( node: &ZNode, topic: &str, discovery_timeout: Duration, ) -> Result> { - // Use the node's auto-discovery method to get subscriber and schema let (subscriber, schema) = node.create_dyn_sub_auto(topic, discovery_timeout).await?; - // Compute type hash for informational purposes - use ros_z::dynamic::MessageSchemaTypeDescription; let type_hash = schema .compute_type_hash() .map(|h| h.to_rihs_string()) .unwrap_or_else(|_| "unknown".to_string()); - // Create channel for async message handling let (tx, rx) = flume::unbounded(); - // Spawn background task to forward messages to channel let subscriber = Arc::new(subscriber); let sub_clone = subscriber.clone(); tokio::task::spawn_blocking(move || { @@ -69,7 +66,6 @@ impl DynamicTopicSubscriber { match sub_clone.recv() { Ok(msg) => { if tx.send(msg).is_err() { - // Receiver dropped, exit task break; } } @@ -90,29 +86,17 @@ impl DynamicTopicSubscriber { }) } - /// Get the discovered message schema + /// Get the discovered message schema. pub fn schema(&self) -> &MessageSchema { &self.schema } - /// Get the RIHS01 type hash + /// Get the RIHS01 type hash. pub fn type_hash(&self) -> &str { &self.type_hash } - /// Receive the next message (blocking) - /// - /// Blocks until a message is available or the channel is closed. - #[allow(dead_code)] - pub fn recv(&self) -> Result { - self.message_rx.recv() - } - - /// Try to receive a message without blocking - /// - /// Returns `Ok(Some(msg))` if a message is available, - /// `Ok(None)` if no message is ready, - /// or `Err` if the channel is closed. + /// Try to receive a message without blocking. pub fn try_recv(&self) -> Result, flume::TryRecvError> { match self.message_rx.try_recv() { Ok(msg) => Ok(Some(msg)), @@ -120,22 +104,4 @@ impl DynamicTopicSubscriber { Err(e) => Err(e), } } - - /// Check if there are any messages waiting - #[allow(dead_code)] - pub fn has_messages(&self) -> bool { - !self.message_rx.is_empty() - } - - /// Get the number of messages currently buffered - #[allow(dead_code)] - pub fn message_count(&self) -> usize { - self.message_rx.len() - } -} - -#[cfg(test)] -mod tests { - // Note: Integration tests would require a running ROS system - // Unit tests are limited for this component } diff --git a/flake.nix b/flake.nix index 747d485c..e796c129 100644 --- a/flake.nix +++ b/flake.nix @@ -197,6 +197,8 @@ gopls # Go language server gotools # Go tools (goimports, etc.) delve # Go debugger + vhs # Terminal recording (demo videos) + ttyd # Required by vhs for Set Shell tapes ]; # Python tools (ros-z-py bindings) diff --git a/scripts/demos/generate.nu b/scripts/demos/generate.nu new file mode 100755 index 00000000..da922012 --- /dev/null +++ b/scripts/demos/generate.nu @@ -0,0 +1,143 @@ +#!/usr/bin/env nu + +# Demo video generator for ros-z-console TUI +# +# Usage: nu scripts/demos/generate.nu +# +# Builds binaries, starts Zenoh router + publisher for realistic data, +# runs VHS on each tape, then cleans up background processes. + +const ROUTER = "tcp/127.0.0.1:7447" + +def main [] { + # Resolve worktree root from this script's location (scripts/demos/generate.nu → ../../) + let worktree = ($env.CURRENT_FILE | path dirname | path dirname | path dirname) + cd $worktree + + # --- Preflight checks --- + for tool in ["vhs", "zenohd", "ttyd"] { + if (which $tool | is-empty) { + error make { msg: $"Required tool not found: ($tool). Run `nix develop` to enter the dev shell." } + } + } + + # Kill any orphaned processes from previous runs (VHS leaks ttyd + chromium) + do -i { ^pkill zenohd } + do -i { ^pkill ttyd } + do -i { ^pkill -9 -f chromium } + sleep 2sec + + # --- Build --- + print "Building ros-z-console..." + cargo build -p ros-z-console + + print "Building z_pubsub example..." + cargo build --example z_pubsub + + print "Building z_srvcli example..." + cargo build --example z_srvcli + + mkdir scripts/demos/results + mkdir _tmp + + # --- Start background processes --- + print "Starting zenohd..." + let zenohd_pid = ( + ^bash -c $"zenohd > _tmp/zenohd-demo.log 2>&1 & echo $!" + | str trim + | into int + ) + sleep 1sec + + print "Starting z_pubsub talker..." + let pubsub_pid = ( + ^bash -c $"./target/debug/examples/z_pubsub --role talker > _tmp/pubsub-demo.log 2>&1 & echo $!" + | str trim + | into int + ) + sleep 1sec + + print "Starting z_pubsub listener..." + let listener_pid = ( + ^bash -c $"./target/debug/examples/z_pubsub --role listener > _tmp/listener-demo.log 2>&1 & echo $!" + | str trim + | into int + ) + sleep 1sec + + print "Starting z_srvcli server..." + let srvcli_pid = ( + ^bash -c $"./target/debug/examples/z_srvcli > _tmp/srvcli-demo.log 2>&1 & echo $!" + | str trim + | into int + ) + sleep 1sec + + print $"zenohd PID: ($zenohd_pid) z_pubsub PID: ($pubsub_pid) listener PID: ($listener_pid) z_srvcli PID: ($srvcli_pid)" + + # --- Run tapes --- + let all_tapes = (ls scripts/demos/tapes/*.tape | get name | sort) + + # 00-warmup.tape runs first to prime chromium/ttyd (cold-start latency absorber) + # Retry until chromium is ready (can take several attempts on cold start) + print "\nWarming up VHS (chromium cold start)..." + mut warmed = false + for attempt in 1..10 { + let ok = (try { ^vhs ($all_tapes | first); true } catch { false }) + if $ok { + print $" Ready after ($attempt) attempts" + $warmed = true + break + } + print $" Attempt ($attempt) failed, retrying in 3s..." + sleep 3sec + } + if not $warmed { + print " Warning: warmup never succeeded, proceeding anyway" + } + + # Real tapes (skip 00-warmup) + let tapes = ($all_tapes | skip 1) + + let results = ($tapes | each {|tape| + print $"\nRunning tape: ($tape)" + mut success = false + for attempt in 1..10 { + let ok = (try { ^vhs $tape; true } catch { false }) + if $ok { + $success = true + break + } + print $" Attempt ($attempt) failed, retrying in 3s..." + sleep 3sec + } + if $success { + print $"✅ ($tape)" + # Clean up leaked VHS processes between tapes to prevent RAM exhaustion + do -i { ^pkill -9 ttyd } + do -i { ^pkill -9 -f chromium } + sleep 5sec + } else { + print $"❌ ($tape)" + } + $success + }) + let passed = ($results | where $it | length) + let failed = ($results | where { not $in } | length) + + # --- Cleanup --- + print "\nCleaning up..." + do -i { ^kill $pubsub_pid } + do -i { ^kill $listener_pid } + do -i { ^kill $srvcli_pid } + do -i { ^kill $zenohd_pid } + do -i { ^pkill ttyd } + do -i { ^pkill -f chromium } + sleep 500ms + + # --- Summary --- + print $"\n=== Demo generation complete ===" + print $"Passed: ($passed) / ($passed + $failed)" + print $"Output: scripts/demos/results/" + ls scripts/demos/results/ | select name size | print +} diff --git a/scripts/demos/tapes/00-warmup.tape b/scripts/demos/tapes/00-warmup.tape new file mode 100644 index 00000000..ef9f7c1b --- /dev/null +++ b/scripts/demos/tapes/00-warmup.tape @@ -0,0 +1,12 @@ +# VHS warmup tape — primes chromium/ttyd before real tapes run. +# Output is discarded; this exists only to absorb the cold-start delay. + +Output scripts/demos/results/00-warmup.gif +Set FontSize 14 +Set Width 400 +Set Height 200 +Set Shell nu + +Type "echo ready" +Enter +Sleep 1s diff --git a/scripts/demos/tapes/01-startup.tape b/scripts/demos/tapes/01-startup.tape new file mode 100644 index 00000000..94d5a393 --- /dev/null +++ b/scripts/demos/tapes/01-startup.tape @@ -0,0 +1,25 @@ +# VHS Tape: Startup +# Shows ros-z-console connecting to a Zenoh router and discovering topics/nodes + +Output scripts/demos/results/01-startup.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" + +# Launch the console (router started by generate.nu) +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter + +# Wait for initial discovery +Sleep 3s +Screenshot scripts/demos/results/01-startup-connected.png + +# Wait another cycle to show live updates +Sleep 3s +Screenshot scripts/demos/results/01-startup-populated.png + +# Quit +Type "q" +Sleep 500ms diff --git a/scripts/demos/tapes/02-navigation.tape b/scripts/demos/tapes/02-navigation.tape new file mode 100644 index 00000000..b625ec9f --- /dev/null +++ b/scripts/demos/tapes/02-navigation.tape @@ -0,0 +1,56 @@ +# VHS Tape: Panel Navigation +# Demonstrates Tab switching between panels and list/detail pane navigation + +Output scripts/demos/results/02-navigation.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 3s + +# Topics panel (default) +Screenshot scripts/demos/results/02-nav-topics.png + +# Tab → Services +Tab +Sleep 500ms +Screenshot scripts/demos/results/02-nav-services.png + +# Tab → Nodes +Tab +Sleep 500ms +Screenshot scripts/demos/results/02-nav-nodes.png + +# Tab → Measure +Tab +Sleep 500ms +Screenshot scripts/demos/results/02-nav-measure.png + +# Back to Topics (Shift+Tab × 3) +Shift+Tab +Shift+Tab +Shift+Tab +Sleep 500ms + +# Enter detail pane with 'l' +Type "l" +Sleep 500ms +Screenshot scripts/demos/results/02-nav-detail.png + +# Navigate sections in detail pane (j/k) +Type "j" +Sleep 300ms +Type "k" +Sleep 300ms + +# Return to list pane with 'h' +Type "h" +Sleep 500ms +Screenshot scripts/demos/results/02-nav-list.png + +Type "q" +Sleep 500ms diff --git a/scripts/demos/tapes/03-filter.tape b/scripts/demos/tapes/03-filter.tape new file mode 100644 index 00000000..76fc27a7 --- /dev/null +++ b/scripts/demos/tapes/03-filter.tape @@ -0,0 +1,40 @@ +# VHS Tape: Filter Mode +# Demonstrates type-ahead search with '/' to narrow down topic list + +Output scripts/demos/results/03-filter.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 3s + +# Full topic list +Screenshot scripts/demos/results/03-filter-before.png + +# Enter filter mode +Type "/" +Sleep 300ms +Screenshot scripts/demos/results/03-filter-mode.png + +# Type partial match +Type "chatter" +Sleep 500ms +Screenshot scripts/demos/results/03-filter-match.png + +# Clear and try another filter +Ctrl+u +Sleep 300ms +Type "chat" +Sleep 500ms + +# Exit filter +Escape +Sleep 800ms +Screenshot scripts/demos/results/03-filter-cleared.png + +Type "q" +Sleep 500ms diff --git a/scripts/demos/tapes/04-help.tape b/scripts/demos/tapes/04-help.tape new file mode 100644 index 00000000..2b56eb96 --- /dev/null +++ b/scripts/demos/tapes/04-help.tape @@ -0,0 +1,26 @@ +# VHS Tape: Help Overlay +# Shows the '?' help overlay with all keybindings + +Output scripts/demos/results/04-help.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 3s + +# Show help overlay +Type "?" +Sleep 500ms +Screenshot scripts/demos/results/04-help-overlay.png + +# Dismiss with '?' +Type "?" +Sleep 300ms +Screenshot scripts/demos/results/04-help-dismissed.png + +Type "q" +Sleep 500ms diff --git a/scripts/demos/tapes/05-topic-detail.tape b/scripts/demos/tapes/05-topic-detail.tape new file mode 100644 index 00000000..aea28918 --- /dev/null +++ b/scripts/demos/tapes/05-topic-detail.tape @@ -0,0 +1,36 @@ +# VHS Tape: Topic Detail +# Selects /chatter in the Topics panel and drills into the detail pane +# showing publishers, subscribers, type hash, and QoS profiles + +Output scripts/demos/results/05-topic-detail.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 3s + +# Topics panel, /chatter selected +Screenshot scripts/demos/results/05-topic-list.png + +# Open detail pane +Type "l" +Sleep 500ms +Screenshot scripts/demos/results/05-topic-detail-top.png + +# Navigate down through sections (publishers → subscribers → QoS) +Type "j" +Sleep 400ms +Type "j" +Sleep 400ms +Screenshot scripts/demos/results/05-topic-detail-qos.png + +# Return to list +Type "h" +Sleep 300ms + +Type "q" +Sleep 500ms diff --git a/scripts/demos/tapes/06-services.tape b/scripts/demos/tapes/06-services.tape new file mode 100644 index 00000000..59435053 --- /dev/null +++ b/scripts/demos/tapes/06-services.tape @@ -0,0 +1,31 @@ +# VHS Tape: Services Panel +# Switches to the Services panel and browses the available services +# including the AddTwoInts service from z_srvcli + +Output scripts/demos/results/06-services.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 3s + +# Navigate to Services panel with key '2' +Type "2" +Sleep 500ms +Screenshot scripts/demos/results/06-services-list.png + +# Open detail pane for the first service +Type "l" +Sleep 500ms +Screenshot scripts/demos/results/06-services-detail.png + +# Return to list +Type "h" +Sleep 300ms + +Type "q" +Sleep 500ms diff --git a/scripts/demos/tapes/07-nodes.tape b/scripts/demos/tapes/07-nodes.tape new file mode 100644 index 00000000..ef9ad54c --- /dev/null +++ b/scripts/demos/tapes/07-nodes.tape @@ -0,0 +1,44 @@ +# VHS Tape: Nodes Panel +# Switches to the Nodes panel, shows discovered nodes (talker, listener, srvcli), +# then drills into a node to see its associated publishers/subscribers/services + +Output scripts/demos/results/07-nodes.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 3s + +# Navigate to Nodes panel with key '3' +Type "3" +Sleep 500ms +Screenshot scripts/demos/results/07-nodes-list.png + +# Navigate past ros_z_console to a node with topics (/Sub or /Pub) +Type "j" +Sleep 200ms +Type "j" +Sleep 200ms + +# Open detail pane +Type "l" +Sleep 500ms +Screenshot scripts/demos/results/07-nodes-detail.png + +# Scroll through the node's topics/services +Type "j" +Sleep 400ms +Type "j" +Sleep 400ms +Screenshot scripts/demos/results/07-nodes-detail-scrolled.png + +# Return to list +Type "h" +Sleep 300ms + +Type "q" +Sleep 500ms diff --git a/scripts/demos/tapes/08-measurement.tape b/scripts/demos/tapes/08-measurement.tape new file mode 100644 index 00000000..13d5a5fc --- /dev/null +++ b/scripts/demos/tapes/08-measurement.tape @@ -0,0 +1,35 @@ +# VHS Tape: Rate Measurement +# Shows the rate measurement workflow: +# r → quick rate check on selected topic (cached result in topic list) +# m → open Measure panel for continuous measurement with time-series chart + +Output scripts/demos/results/08-measurement.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 3s + +# Topics panel with /chatter selected +Screenshot scripts/demos/results/08-before.png + +# Quick rate check with 'r' +Type "r" +Sleep 4s +Screenshot scripts/demos/results/08-rate-result.png + +# Open Measure panel with 'm' for continuous measurement +Type "m" +Sleep 500ms +Screenshot scripts/demos/results/08-measure-panel.png + +# Let the time-series chart accumulate data +Sleep 5s +Screenshot scripts/demos/results/08-measure-chart.png + +Type "q" +Sleep 500ms diff --git a/tests/run-vhs-tests.nu b/tests/run-vhs-tests.nu new file mode 100755 index 00000000..e7b379e0 --- /dev/null +++ b/tests/run-vhs-tests.nu @@ -0,0 +1,349 @@ +#!/usr/bin/env nu + +# VHS-based TUI correctness tests for ros-z-console +# +# Usage: +# nu tests/run-vhs-tests.nu +# +# Prerequisites (run once): +# nix develop # enter dev shell to get vhs, zenohd, ttyd in PATH +# +# Background processes started automatically: +# zenohd - Zenoh router +# z_pubsub talker - Publishes std_msgs/String on /chatter at 1 Hz +# z_pubsub listener - Subscribes to /chatter (makes the topic bidirectional) +# z_srvcli server - Exposes example_interfaces/AddTwoInts service +# +# Assertions: +# - Screenshot existence = app ran that code path without crashing +# - Size differences = state actually changed (filtered vs unfiltered, etc.) +# - All screenshots exist = test passed + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def run_tape [tape_path: string] { + let before = (date now) + let result = (try { ^vhs $tape_path | complete } catch { {exit_code: 1, stderr: "vhs threw"} }) + let after = (date now) + { + tape: ($tape_path | path basename) + success: ($result.exit_code == 0) + duration_ms: (($after - $before) / 1ms) + exit_code: $result.exit_code + stderr: ($result.stderr? | default "") + } +} + +def shot [path: string] { + let exists = ($path | path exists) + let size = (if $exists { ls $path | get size | first | into int } else { 0 }) + { path: $path, exists: $exists, size: $size } +} + +def sizes_differ [a: string, b: string] { + let sa = (shot $a) + let sb = (shot $b) + ($sa.exists and $sb.exists and $sa.size != $sb.size) +} + +def make_result [name: string, checks: record, error: string] { + let all_pass = ($checks | values | all {|v| $v }) + { + name: $name + status: (if $all_pass { "passed" } else { "failed" }) + checks: $checks + error: (if $all_pass { null } else { $error }) + } +} + +# --------------------------------------------------------------------------- +# Per-tape analysis functions +# --------------------------------------------------------------------------- + +def analyze_topics_panel [] { + let initial = (shot "tests/results/01-topics-initial.png") + let nav_down = (shot "tests/results/01-topics-nav-down.png") + let nav_up = (shot "tests/results/01-topics-nav-up.png") + + let checks = { + initial_captured: $initial.exists + nav_down_captured: $nav_down.exists + nav_up_captured: $nav_up.exists + # /chatter topic discovered: initial screenshot should be a real TUI frame (>50 KB) + initial_has_content: ($initial.size > 50000) + # initial shows TUI (not direnv loading): should be at least 100 KB with color theme + initial_is_tui: ($initial.size > 100000) + } + make_result "topics_panel" $checks "Topics panel did not load or /chatter not discovered" +} + +def analyze_panel_navigation [] { + let topics = (shot "tests/results/02-topics-panel.png") + let services = (shot "tests/results/02-services-panel.png") + let nodes = (shot "tests/results/02-nodes-panel.png") + let measure = (shot "tests/results/02-measure-panel.png") + let tab_back = (shot "tests/results/02-tab-back-to-topics.png") + + let checks = { + topics_captured: $topics.exists + services_captured: $services.exists + nodes_captured: $nodes.exists + measure_captured: $measure.exists + tab_back_captured: $tab_back.exists + # Each panel renders differently (panel header + content changes) + topics_vs_services: (sizes_differ "tests/results/02-topics-panel.png" "tests/results/02-services-panel.png") + services_vs_nodes: (sizes_differ "tests/results/02-services-panel.png" "tests/results/02-nodes-panel.png") + nodes_vs_measure: (sizes_differ "tests/results/02-nodes-panel.png" "tests/results/02-measure-panel.png") + } + make_result "panel_navigation" $checks "Panel switching (1/2/3/4/Tab) did not change display" +} + +def analyze_topic_detail [] { + let list_pane = (shot "tests/results/03-list-pane.png") + let detail_pane = (shot "tests/results/03-detail-pane.png") + let back_to_list = (shot "tests/results/03-back-to-list.png") + let via_enter = (shot "tests/results/03-detail-via-enter.png") + + let checks = { + list_captured: $list_pane.exists + detail_captured: $detail_pane.exists + back_captured: $back_to_list.exists + enter_captured: $via_enter.exists + # l moves focus to detail pane: content should differ from list pane + l_key_changes_pane: (sizes_differ "tests/results/03-list-pane.png" "tests/results/03-detail-pane.png") + # h returns: back-to-list should look like list-pane + h_key_returns: (sizes_differ "tests/results/03-detail-pane.png" "tests/results/03-back-to-list.png") + # Enter reaches detail same as l + enter_shows_detail: (sizes_differ "tests/results/03-list-pane.png" "tests/results/03-detail-via-enter.png") + } + make_result "topic_detail" $checks "l/h keys did not switch between list and detail panes" +} + +def analyze_filter [] { + let before = (shot "tests/results/04-filter-before.png") + let active = (shot "tests/results/04-filter-active.png") + let matched = (shot "tests/results/04-filter-match.png") + let cleared = (shot "tests/results/04-filter-cleared.png") + let exited = (shot "tests/results/04-filter-exited.png") + + let checks = { + before_captured: $before.exists + active_captured: $active.exists + matched_captured: $matched.exists + cleared_captured: $cleared.exists + exited_captured: $exited.exists + # / activates filter mode (status bar changes) + slash_changes_display: (sizes_differ "tests/results/04-filter-before.png" "tests/results/04-filter-active.png") + # Typing "chatter" narrows the list (fewer rows = different size) + typing_filters_list: (sizes_differ "tests/results/04-filter-before.png" "tests/results/04-filter-match.png") + # Ctrl+U clears text (list expands again) + ctrl_u_clears: (sizes_differ "tests/results/04-filter-match.png" "tests/results/04-filter-cleared.png") + } + make_result "filter_mode" $checks "Filter mode (/) did not narrow the topic list" +} + +def analyze_help_overlay [] { + let off = (shot "tests/results/05-help-off.png") + let on = (shot "tests/results/05-help-on.png") + let dismissed = (shot "tests/results/05-help-dismissed.png") + let escaped = (shot "tests/results/05-help-escape.png") + + let checks = { + off_captured: $off.exists + on_captured: $on.exists + dismissed_captured: $dismissed.exists + escaped_captured: $escaped.exists + # ? shows overlay (different render = different size) + question_shows_overlay: (sizes_differ "tests/results/05-help-off.png" "tests/results/05-help-on.png") + # second ? dismisses overlay + second_question_dismisses: (sizes_differ "tests/results/05-help-on.png" "tests/results/05-help-dismissed.png") + # Escape also dismisses + escape_dismisses: (sizes_differ "tests/results/05-help-on.png" "tests/results/05-help-escape.png") + } + make_result "help_overlay" $checks "? key did not show/dismiss help overlay" +} + +def analyze_rate_check [] { + let before = (shot "tests/results/06-rate-before.png") + let measuring = (shot "tests/results/06-rate-measuring.png") + let done = (shot "tests/results/06-rate-done.png") + + let checks = { + before_captured: $before.exists + measuring_captured: $measuring.exists + done_captured: $done.exists + # After measurement completes, rate value appears in topic list (list content changes) + # Note: measuring vs before sizes are similar because status text change has minimal PNG impact + rate_shown_after: (sizes_differ "tests/results/06-rate-before.png" "tests/results/06-rate-done.png") + } + make_result "rate_check" $checks "r key rate measurement did not update /chatter rate display" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main [] { + # Resolve worktree root from this script's location (tests/run-vhs-tests.nu → ../) + let worktree = ($env.CURRENT_FILE | path dirname | path dirname) + cd $worktree + + # --- Preflight --- + for tool in ["vhs", "zenohd", "ttyd"] { + if (which $tool | is-empty) { + error make { msg: $"Required tool not found: ($tool). Run `nix develop` first." } + } + } + + # Kill orphaned processes from previous runs + do -i { ^pkill zenohd } + do -i { ^pkill -9 ttyd } + do -i { ^pkill -9 -f chromium } + sleep 2sec + + # --- Build --- + print "Building ros-z-console..." + cargo build -p ros-z-console + + print "Building examples (z_pubsub, z_srvcli)..." + cargo build --example z_pubsub + cargo build --example z_srvcli + + mkdir tests/results + mkdir _tmp + + # --- Start background processes --- + print "Starting zenohd..." + let zenohd_pid = ( + ^bash -c $"zenohd > _tmp/zenohd-test.log 2>&1 & echo $!" + | str trim | into int + ) + sleep 1sec + + print "Starting z_pubsub talker (/chatter publisher)..." + let talker_pid = ( + ^bash -c $"./target/debug/examples/z_pubsub --role talker > _tmp/talker-test.log 2>&1 & echo $!" + | str trim | into int + ) + + print "Starting z_pubsub listener (/chatter subscriber)..." + let listener_pid = ( + ^bash -c $"./target/debug/examples/z_pubsub --role listener > _tmp/listener-test.log 2>&1 & echo $!" + | str trim | into int + ) + + print "Starting z_srvcli server (AddTwoInts service)..." + let srvcli_pid = ( + ^bash -c $"./target/debug/examples/z_srvcli > _tmp/srvcli-test.log 2>&1 & echo $!" + | str trim | into int + ) + sleep 3sec + + print $"PIDs: zenohd=($zenohd_pid) talker=($talker_pid) listener=($listener_pid) srvcli=($srvcli_pid)" + + # --- Warmup --- + let all_tapes = (ls tests/tapes/*.tape | get name | sort) + + print "\nWarming up VHS (chromium cold start)..." + mut warmed = false + for attempt in 1..10 { + let ok = (try { ^vhs ($all_tapes | first); true } catch { false }) + if $ok { + print $" Ready after ($attempt) attempts" + $warmed = true + break + } + print $" Attempt ($attempt) failed, retrying in 3s..." + sleep 3sec + } + if not $warmed { + print " Warning: warmup never succeeded, proceeding anyway" + } + + # --- Run tapes --- + let tapes = ($all_tapes | skip 1) + + let tape_results = ($tapes | each {|tape| + print $"\nRunning tape: ($tape)" + mut success = false + for attempt in 1..10 { + let ok = (try { ^vhs $tape; true } catch { false }) + if $ok { + $success = true + break + } + print $" Attempt ($attempt) failed, retrying in 3s..." + sleep 3sec + } + if $success { + print $" ✅ ($tape | path basename)" + do -i { ^pkill -9 ttyd } + do -i { ^pkill -9 -f chromium } + sleep 5sec + } else { + print $" ❌ ($tape | path basename)" + } + { tape: ($tape | path basename), success: $success } + }) + + # --- Analyze --- + print "\nAnalyzing screenshots..." + let tests = [ + (analyze_topics_panel) + (analyze_panel_navigation) + (analyze_topic_detail) + (analyze_filter) + (analyze_help_overlay) + (analyze_rate_check) + ] + + for t in $tests { + let icon = (if $t.status == "passed" { "✅" } else { "❌" }) + print $" ($icon) ($t.name)" + if $t.status == "failed" { + print $" error: ($t.error)" + let failing = ($t.checks | transpose key val | where { not $in.val }) + for f in $failing { + print $" - ($f.key): false" + } + } + } + + # --- Cleanup --- + print "\nCleaning up..." + do -i { ^kill $talker_pid } + do -i { ^kill $listener_pid } + do -i { ^kill $srvcli_pid } + do -i { ^kill $zenohd_pid } + do -i { ^pkill ttyd } + do -i { ^pkill -f chromium } + sleep 500ms + + # --- Report --- + let passed = ($tests | where status == "passed" | length) + let failed = ($tests | where status == "failed" | length) + + let report = { + timestamp: (date now | format date '%Y-%m-%dT%H:%M:%S') + total: ($tests | length) + passed: $passed + failed: $failed + tape_results: $tape_results + tests: $tests + } + + let ts = (date now | format date '%Y%m%d-%H%M%S') + $report | to json | save $"tests/results/test-run-($ts).json" + $report | to json | save --force "tests/results/latest.json" + + print $"\n=== TUI Test Results: ($passed)/($passed + $failed) passed ===" + print $"Report: tests/results/latest.json" + ls tests/results/*.png | select name size | print + + if $failed > 0 { + exit 1 + } +} diff --git a/tests/tapes/00-warmup.tape b/tests/tapes/00-warmup.tape new file mode 100644 index 00000000..43c8f1b4 --- /dev/null +++ b/tests/tapes/00-warmup.tape @@ -0,0 +1,13 @@ +# VHS Tape: Warmup +# Absorbs chromium/ttyd cold-start latency before running real tests. + +Output tests/results/00-warmup.gif +Set FontSize 14 +Set Width 400 +Set Height 200 +Set Shell nu +Env DIRENV_DISABLE "1" + +Type "echo ready" +Enter +Sleep 1s diff --git a/tests/tapes/01-topics-panel.tape b/tests/tapes/01-topics-panel.tape new file mode 100644 index 00000000..6c7b9e09 --- /dev/null +++ b/tests/tapes/01-topics-panel.tape @@ -0,0 +1,35 @@ +# VHS Tape: Topics Panel +# +# Verifies: +# - Console starts and connects to zenohd +# - /chatter topic is discovered and visible in Topics panel +# - j/k keys navigate the topic list +# - q quits cleanly + +Output tests/results/01-topics-panel.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" +Env DIRENV_DISABLE "1" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 4s + +# Capture initial state: Topics panel visible, /chatter should appear +Screenshot tests/results/01-topics-initial.png + +# Navigate down with j +Type "j" +Sleep 200ms +Screenshot tests/results/01-topics-nav-down.png + +# Navigate back up with k +Type "k" +Sleep 200ms +Screenshot tests/results/01-topics-nav-up.png + +Type "q" +Sleep 500ms diff --git a/tests/tapes/02-panel-navigation.tape b/tests/tapes/02-panel-navigation.tape new file mode 100644 index 00000000..4fe5b00a --- /dev/null +++ b/tests/tapes/02-panel-navigation.tape @@ -0,0 +1,48 @@ +# VHS Tape: Panel Navigation +# +# Verifies: +# - Number keys 1/2/3/4 switch between Topics/Services/Nodes/Measure panels +# - Tab cycles forward through panels +# - Each panel shows a distinct header + +Output tests/results/02-panel-navigation.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" +Env DIRENV_DISABLE "1" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 4s + +# Default: Topics panel (panel 1) +Screenshot tests/results/02-topics-panel.png + +# Switch to Services panel via number key +Type "2" +Sleep 300ms +Screenshot tests/results/02-services-panel.png + +# Switch to Nodes panel +Type "3" +Sleep 300ms +Screenshot tests/results/02-nodes-panel.png + +# Switch to Measure panel +Type "4" +Sleep 300ms +Screenshot tests/results/02-measure-panel.png + +# Tab cycles forward: Measure -> Topics +Tab +Sleep 300ms +Screenshot tests/results/02-tab-back-to-topics.png + +# Return to Topics via key 1 +Type "1" +Sleep 200ms + +Type "q" +Sleep 500ms diff --git a/tests/tapes/03-topic-detail.tape b/tests/tapes/03-topic-detail.tape new file mode 100644 index 00000000..78ddf26a --- /dev/null +++ b/tests/tapes/03-topic-detail.tape @@ -0,0 +1,49 @@ +# VHS Tape: Topic Detail Pane +# +# Verifies: +# - l key moves focus to detail pane showing topic info +# - Detail pane shows type name, publisher/subscriber counts, QoS +# - h key returns focus to list pane +# - Enter also enters detail pane + +Output tests/results/03-topic-detail.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" +Env DIRENV_DISABLE "1" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 4s + +# Capture list pane state (left pane focused) +Screenshot tests/results/03-list-pane.png + +# Move to detail pane with l +Type "l" +Sleep 300ms +Screenshot tests/results/03-detail-pane.png + +# Scroll detail pane with j +Type "j" +Sleep 200ms +Screenshot tests/results/03-detail-scrolled.png + +# Return to list pane with h +Type "h" +Sleep 300ms +Screenshot tests/results/03-back-to-list.png + +# Enter also enters detail pane +Enter +Sleep 300ms +Screenshot tests/results/03-detail-via-enter.png + +# Escape returns to list +Escape +Sleep 300ms + +Type "q" +Sleep 500ms diff --git a/tests/tapes/04-filter.tape b/tests/tapes/04-filter.tape new file mode 100644 index 00000000..5fdd5eb8 --- /dev/null +++ b/tests/tapes/04-filter.tape @@ -0,0 +1,45 @@ +# VHS Tape: Filter Mode +# +# Verifies: +# - / activates filter mode (status bar changes) +# - Typing narrows the list to matching topics +# - Ctrl+U clears the filter text +# - Escape exits filter mode, restoring full list + +Output tests/results/04-filter.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" +Env DIRENV_DISABLE "1" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 4s + +# Full topic list visible +Screenshot tests/results/04-filter-before.png + +# Enter filter mode with / +Type "/" +Sleep 200ms +Screenshot tests/results/04-filter-active.png + +# Type search string - should narrow list to /chatter +Type "chatter" +Sleep 300ms +Screenshot tests/results/04-filter-match.png + +# Clear with Ctrl+U +Ctrl+U +Sleep 200ms +Screenshot tests/results/04-filter-cleared.png + +# Exit filter mode with Escape +Escape +Sleep 600ms +Screenshot tests/results/04-filter-exited.png + +Type "q" +Sleep 500ms diff --git a/tests/tapes/05-help-overlay.tape b/tests/tapes/05-help-overlay.tape new file mode 100644 index 00000000..00f526fa --- /dev/null +++ b/tests/tapes/05-help-overlay.tape @@ -0,0 +1,41 @@ +# VHS Tape: Help Overlay +# +# Verifies: +# - ? shows the help overlay with keybinding table +# - ? again dismisses the overlay +# - q while overlay is shown also dismisses it (Esc/q/? all close help) + +Output tests/results/05-help-overlay.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" +Env DIRENV_DISABLE "1" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 3s + +# Normal TUI without overlay +Screenshot tests/results/05-help-off.png + +# Show help overlay with ? +Type "?" +Sleep 300ms +Screenshot tests/results/05-help-on.png + +# Dismiss with ? again +Type "?" +Sleep 300ms +Screenshot tests/results/05-help-dismissed.png + +# Show again and dismiss with Escape +Type "?" +Sleep 300ms +Escape +Sleep 300ms +Screenshot tests/results/05-help-escape.png + +Type "q" +Sleep 500ms diff --git a/tests/tapes/06-rate-check.tape b/tests/tapes/06-rate-check.tape new file mode 100644 index 00000000..83130e4f --- /dev/null +++ b/tests/tapes/06-rate-check.tape @@ -0,0 +1,33 @@ +# VHS Tape: Rate Check +# +# Verifies: +# - r key triggers a quick rate measurement on the selected topic +# - Status bar shows measurement in progress +# - After measurement completes, rate (msg/s) is shown in the topic list + +Output tests/results/06-rate-check.gif +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Shell nu +Set Theme "Catppuccin Mocha" +Env DIRENV_DISABLE "1" + +Type "./target/debug/ros-z-console tcp/127.0.0.1:7447 0" +Enter +Sleep 4s + +# /chatter should be selected (first topic) +Screenshot tests/results/06-rate-before.png + +# Trigger rate measurement with r +Type "r" +Sleep 500ms +Screenshot tests/results/06-rate-measuring.png + +# Wait for measurement to complete (default quick measure: ~3s) +Sleep 4s +Screenshot tests/results/06-rate-done.png + +Type "q" +Sleep 500ms