diff --git a/Cargo.lock b/Cargo.lock index 2614277a..ad22381e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -331,6 +337,7 @@ dependencies = [ "atty", "chrono", "clap", + "criterion", "crossterm 0.29.0", "ctrlc", "directories", @@ -339,9 +346,11 @@ dependencies = [ "futures", "glob", "indicatif", + "insta", "lazy_static", "libc", "lru 0.16.2", + "mockall", "mockito", "once_cell", "owo-colors", @@ -397,6 +406,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -463,6 +478,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -560,6 +602,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "console" version = "0.16.1" @@ -653,6 +707,67 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.28.1" @@ -919,6 +1034,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "dunce" version = "1.0.5" @@ -1125,6 +1246,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "fs_extra" version = "1.3.0" @@ -1313,6 +1440,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1560,7 +1698,7 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ - "console", + "console 0.16.1", "portable-atomic", "unicode-width 0.2.0", "unit-prefix", @@ -1586,6 +1724,17 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "insta" +version = "1.44.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" +dependencies = [ + "console 0.15.11", + "once_cell", + "similar", +] + [[package]] name = "instability" version = "0.3.10" @@ -1627,12 +1776,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi 0.5.2", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1860,6 +2029,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockall" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mockito" version = "1.7.1" @@ -2021,6 +2217,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2217,6 +2419,34 @@ dependencies = [ "spki", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -2255,6 +2485,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2391,7 +2647,7 @@ dependencies = [ "crossterm 0.28.1", "indoc", "instability", - "itertools", + "itertools 0.13.0", "lru 0.12.5", "paste", "strum", @@ -2400,6 +2656,26 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2683,6 +2959,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scc" version = "2.4.0" @@ -3103,6 +3388,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -3161,6 +3452,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tls_codec" version = "0.4.2" @@ -3332,7 +3633,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -3406,6 +3707,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3532,6 +3843,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 58d7f1f2..69fa40de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,4 +62,11 @@ mockito = "1" once_cell = "1.21.3" tokio-test = "0.4" serial_test = "3.2" +insta = "1.34" +criterion = { version = "0.5", features = ["html_reports"] } +mockall = "0.12" + +[[bench]] +name = "large_output_benchmark" +harness = false diff --git a/benches/large_output_benchmark.rs b/benches/large_output_benchmark.rs new file mode 100644 index 00000000..75ee394e --- /dev/null +++ b/benches/large_output_benchmark.rs @@ -0,0 +1,412 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed 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. + +//! Performance Benchmarks for Large Output Handling +//! +//! This module benchmarks: +//! - Large output handling (>10MB) +//! - RollingBuffer overflow behavior +//! - Memory usage under load +//! - Concurrent multi-node streaming + +use bssh::executor::{MultiNodeStreamManager, NodeStream}; +use bssh::node::Node; +use bssh::ssh::tokio_client::CommandOutput; +use bssh::ui::tui::app::TuiApp; +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use ratatui::backend::TestBackend; +use ratatui::Terminal; +use russh::CryptoVec; +use tokio::runtime::Runtime; +use tokio::sync::mpsc; + +/// Create a test runtime for async benchmarks +fn create_runtime() -> Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() +} + +// ============================================================================ +// Large Output Benchmarks +// ============================================================================ + +/// Benchmark NodeStream with large output data +fn bench_large_output_single_stream(c: &mut Criterion) { + let mut group = c.benchmark_group("large_output"); + + // Test different output sizes: 1KB, 100KB, 1MB, 10MB + for size in [1024, 100 * 1024, 1024 * 1024, 10 * 1024 * 1024].iter() { + group.throughput(Throughput::Bytes(*size as u64)); + + group.bench_with_input( + BenchmarkId::new("single_stream", format!("{size} bytes")), + size, + |b, &size| { + let rt = create_runtime(); + b.iter(|| { + rt.block_on(async { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(1000); + let mut stream = NodeStream::new(node, rx); + + // Send data in 32KB chunks (typical SSH packet size) + let chunk_size = 32 * 1024; + let chunk = CryptoVec::from(vec![b'x'; chunk_size.min(size)]); + let num_chunks = size.div_ceil(chunk_size); + + for _ in 0..num_chunks { + let _ = tx.send(CommandOutput::StdOut(chunk.clone())).await; + } + drop(tx); + + // Poll all data + while stream.poll() { + // Continue polling + } + + black_box(stream.stdout().len()) + }) + }); + }, + ); + } + + group.finish(); +} + +/// Benchmark RollingBuffer with overflow (exceeding 10MB limit) +fn bench_rolling_buffer_overflow(c: &mut Criterion) { + let mut group = c.benchmark_group("rolling_buffer_overflow"); + + // Test writing more than 10MB to trigger overflow + for overflow_factor in [1.5_f64, 2.0_f64, 3.0_f64].iter() { + let total_size = (10.0 * 1024.0 * 1024.0 * overflow_factor) as usize; + group.throughput(Throughput::Bytes(total_size as u64)); + + group.bench_with_input( + BenchmarkId::new("overflow", format!("{overflow_factor}x")), + &total_size, + |b, &total_size| { + let rt = create_runtime(); + b.iter(|| { + rt.block_on(async { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(1000); + let mut stream = NodeStream::new(node, rx); + + // Send data in chunks to exceed buffer limit + let chunk_size = 64 * 1024; // 64KB chunks + let chunk = CryptoVec::from(vec![b'x'; chunk_size]); + let num_chunks = total_size / chunk_size; + + for _ in 0..num_chunks { + let _ = tx.send(CommandOutput::StdOut(chunk.clone())).await; + // Poll periodically to simulate real usage + stream.poll(); + } + drop(tx); + + while stream.poll() {} + + // Buffer should be limited to 10MB + black_box(stream.stdout().len()) + }) + }); + }, + ); + } + + group.finish(); +} + +// ============================================================================ +// Multi-Node Streaming Benchmarks +// ============================================================================ + +/// Benchmark concurrent multi-node streaming +fn bench_concurrent_multi_node(c: &mut Criterion) { + let mut group = c.benchmark_group("concurrent_multi_node"); + + for num_nodes in [4, 16, 64].iter() { + group.bench_with_input( + BenchmarkId::new("nodes", num_nodes), + num_nodes, + |b, &num_nodes| { + let rt = create_runtime(); + b.iter(|| { + rt.block_on(async { + let mut manager = MultiNodeStreamManager::new(); + let mut senders = Vec::new(); + + // Create all node streams + for i in 0..num_nodes { + let node = Node::new(format!("host{i}"), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + senders.push(tx); + } + + // Send data to all nodes + let data_per_node = 100 * 1024; // 100KB per node + let chunk = CryptoVec::from(vec![b'x'; 1024]); + let chunks_per_node = data_per_node / 1024; + + for _ in 0..chunks_per_node { + for tx in &senders { + let _ = tx.send(CommandOutput::StdOut(chunk.clone())).await; + } + manager.poll_all(); + } + + // Close all channels + for tx in senders { + drop(tx); + } + + while manager.poll_all() {} + + black_box(manager.completed_count()) + }) + }); + }, + ); + } + + group.finish(); +} + +/// Benchmark manager poll_all with varying data rates +fn bench_poll_all_throughput(c: &mut Criterion) { + let mut group = c.benchmark_group("poll_all_throughput"); + + for chunk_size in [256, 1024, 8192, 32768].iter() { + group.throughput(Throughput::Bytes(*chunk_size as u64 * 10)); + + group.bench_with_input( + BenchmarkId::new("chunk_size", chunk_size), + chunk_size, + |b, &chunk_size| { + let rt = create_runtime(); + b.iter(|| { + rt.block_on(async { + let mut manager = MultiNodeStreamManager::new(); + let mut senders = Vec::new(); + + // Create 10 node streams + for i in 0..10 { + let node = Node::new(format!("host{i}"), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + senders.push(tx); + } + + let chunk = CryptoVec::from(vec![b'x'; chunk_size]); + + // Send one chunk to each node and poll + for tx in &senders { + let _ = tx.send(CommandOutput::StdOut(chunk.clone())).await; + } + + black_box(manager.poll_all()) + }) + }); + }, + ); + } + + group.finish(); +} + +// ============================================================================ +// TUI Rendering Benchmarks +// ============================================================================ + +/// Benchmark TUI rendering with many nodes +fn bench_tui_render_summary(c: &mut Criterion) { + let mut group = c.benchmark_group("tui_render_summary"); + + for num_nodes in [5, 20, 50].iter() { + group.bench_with_input( + BenchmarkId::new("nodes", num_nodes), + num_nodes, + |b, &num_nodes| { + let mut manager = MultiNodeStreamManager::new(); + for i in 0..num_nodes { + let node = Node::new(format!("host{i}.example.com"), 22, format!("user{i}")); + let (_tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + } + + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).unwrap(); + + b.iter(|| { + terminal + .draw(|f| { + bssh::ui::tui::views::summary::render( + f, + &manager, + "benchmark-cluster", + "echo test", + false, + ); + }) + .unwrap(); + black_box(()) + }); + }, + ); + } + + group.finish(); +} + +/// Benchmark TUI rendering with output data +fn bench_tui_render_detail(c: &mut Criterion) { + let mut group = c.benchmark_group("tui_render_detail"); + + for output_lines in [100, 1000, 10000].iter() { + group.bench_with_input( + BenchmarkId::new("lines", output_lines), + output_lines, + |b, &output_lines| { + let rt = create_runtime(); + + // Pre-create the stream with output data + let node = Node::new( + "benchmark-host.example.com".to_string(), + 22, + "user".to_string(), + ); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Generate output + let mut output = String::new(); + for i in 0..output_lines { + output.push_str(&format!( + "Line {i}: This is a test line with some content\n" + )); + } + + rt.block_on(async { + tx.send(CommandOutput::StdOut(CryptoVec::from( + output.as_bytes().to_vec(), + ))) + .await + .unwrap(); + drop(tx); + }); + stream.poll(); + + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).unwrap(); + + b.iter(|| { + terminal + .draw(|f| { + bssh::ui::tui::views::detail::render(f, &stream, 0, 0, false, false); + }) + .unwrap(); + black_box(()) + }); + }, + ); + } + + group.finish(); +} + +// ============================================================================ +// Data Change Detection Benchmarks +// ============================================================================ + +/// Benchmark TuiApp data change detection +fn bench_data_change_detection(c: &mut Criterion) { + let mut group = c.benchmark_group("data_change_detection"); + + for num_nodes in [10, 50, 100].iter() { + group.bench_with_input( + BenchmarkId::new("nodes", num_nodes), + num_nodes, + |b, &num_nodes| { + let mut manager = MultiNodeStreamManager::new(); + for i in 0..num_nodes { + let node = Node::new(format!("host{i}"), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + } + + let mut app = TuiApp::new(); + // Initial detection + app.check_data_changes(manager.streams()); + + b.iter(|| black_box(app.check_data_changes(manager.streams()))); + }, + ); + } + + group.finish(); +} + +// ============================================================================ +// Memory Usage Benchmarks +// ============================================================================ + +/// Benchmark memory allocation patterns +fn bench_memory_allocation(c: &mut Criterion) { + let mut group = c.benchmark_group("memory_allocation"); + group.sample_size(50); // Reduce sample size for memory tests + + group.bench_function("create_100_streams", |b| { + b.iter(|| { + let mut manager = MultiNodeStreamManager::new(); + for i in 0..100 { + let node = Node::new(format!("host{i}"), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + } + black_box(manager.total_count()) + }); + }); + + group.bench_function("create_tui_app", |b| { + b.iter(|| { + let app = TuiApp::new(); + black_box(app) + }); + }); + + group.finish(); +} + +// ============================================================================ +// Criterion Configuration +// ============================================================================ + +criterion_group!( + benches, + bench_large_output_single_stream, + bench_rolling_buffer_overflow, + bench_concurrent_multi_node, + bench_poll_all_throughput, + bench_tui_render_summary, + bench_tui_render_detail, + bench_data_change_detection, + bench_memory_allocation, +); + +criterion_main!(benches); diff --git a/tests/streaming_integration_tests.rs b/tests/streaming_integration_tests.rs new file mode 100644 index 00000000..1c87eb47 --- /dev/null +++ b/tests/streaming_integration_tests.rs @@ -0,0 +1,642 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed 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. + +//! Streaming Execution Integration Tests +//! +//! This module tests the streaming execution infrastructure including: +//! - NodeStream and RollingBuffer behavior +//! - MultiNodeStreamManager coordination +//! - stdout/stderr separation +//! - Partial output handling +//! - Connection failure scenarios + +use bssh::executor::{ExecutionStatus, MultiNodeStreamManager, NodeStream}; +use bssh::node::Node; +use bssh::ssh::tokio_client::CommandOutput; +use russh::CryptoVec; +use tokio::sync::mpsc; + +// ============================================================================ +// NodeStream Basic Tests +// ============================================================================ + +#[tokio::test] +async fn test_node_stream_creation() { + let node = Node::new("test-host".to_string(), 22, "testuser".to_string()); + let (_tx, rx) = mpsc::channel::(100); + let stream = NodeStream::new(node.clone(), rx); + + assert_eq!(stream.node.host, "test-host"); + assert_eq!(stream.node.port, 22); + assert_eq!(stream.node.username, "testuser"); + assert_eq!(stream.status(), &ExecutionStatus::Pending); + assert_eq!(stream.exit_code(), None); + assert!(!stream.is_closed()); + assert!(!stream.is_complete()); + assert!(stream.stdout().is_empty()); + assert!(stream.stderr().is_empty()); +} + +#[tokio::test] +async fn test_node_stream_receives_stdout() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send stdout data + let data = CryptoVec::from(b"Hello, World!".to_vec()); + tx.send(CommandOutput::StdOut(data)).await.unwrap(); + + // Poll should receive data + assert!(stream.poll(), "Poll should return true when data received"); + assert_eq!(stream.stdout(), b"Hello, World!"); + assert_eq!( + stream.status(), + &ExecutionStatus::Running, + "Status should be Running after receiving data" + ); +} + +#[tokio::test] +async fn test_node_stream_receives_stderr() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send stderr data + let data = CryptoVec::from(b"Error: something went wrong".to_vec()); + tx.send(CommandOutput::StdErr(data)).await.unwrap(); + + stream.poll(); + assert_eq!(stream.stderr(), b"Error: something went wrong"); + assert!(stream.stdout().is_empty(), "stdout should be empty"); +} + +#[tokio::test] +async fn test_node_stream_stdout_stderr_separation() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send both stdout and stderr + let stdout_data = CryptoVec::from(b"stdout output".to_vec()); + let stderr_data = CryptoVec::from(b"stderr output".to_vec()); + tx.send(CommandOutput::StdOut(stdout_data)).await.unwrap(); + tx.send(CommandOutput::StdErr(stderr_data)).await.unwrap(); + + stream.poll(); + assert_eq!(stream.stdout(), b"stdout output"); + assert_eq!(stream.stderr(), b"stderr output"); +} + +#[tokio::test] +async fn test_node_stream_multiple_chunks() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send multiple chunks + for i in 1..=5 { + let data = CryptoVec::from(format!("chunk{i}").into_bytes()); + tx.send(CommandOutput::StdOut(data)).await.unwrap(); + } + + stream.poll(); + assert_eq!(stream.stdout(), b"chunk1chunk2chunk3chunk4chunk5"); +} + +#[tokio::test] +async fn test_node_stream_exit_code_success() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send exit code and close channel + tx.send(CommandOutput::ExitCode(0)).await.unwrap(); + drop(tx); + + stream.poll(); + assert_eq!(stream.exit_code(), Some(0)); + assert!(stream.is_closed()); + assert!(stream.is_complete()); + assert_eq!(stream.status(), &ExecutionStatus::Completed); +} + +#[tokio::test] +async fn test_node_stream_exit_code_failure() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send non-zero exit code + tx.send(CommandOutput::ExitCode(1)).await.unwrap(); + drop(tx); + + stream.poll(); + assert_eq!(stream.exit_code(), Some(1)); + assert!(stream.is_complete()); + assert!( + matches!(stream.status(), ExecutionStatus::Failed(msg) if msg.contains("Exit code: 1")), + "Expected Failed status with exit code 1, got {:?}", + stream.status() + ); +} + +#[tokio::test] +async fn test_node_stream_take_buffers() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send data + let stdout = CryptoVec::from(b"stdout data".to_vec()); + let stderr = CryptoVec::from(b"stderr data".to_vec()); + tx.send(CommandOutput::StdOut(stdout)).await.unwrap(); + tx.send(CommandOutput::StdErr(stderr)).await.unwrap(); + + stream.poll(); + + // Take stdout + let taken_stdout = stream.take_stdout(); + assert_eq!(taken_stdout, b"stdout data"); + assert!( + stream.stdout().is_empty(), + "stdout should be empty after take" + ); + + // Take stderr + let taken_stderr = stream.take_stderr(); + assert_eq!(taken_stderr, b"stderr data"); + assert!( + stream.stderr().is_empty(), + "stderr should be empty after take" + ); +} + +#[tokio::test] +async fn test_node_stream_channel_disconnect() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Just drop the sender without sending exit code + drop(tx); + + stream.poll(); + assert!(stream.is_closed()); + assert!(stream.is_complete()); + // Should complete successfully without exit code + assert_eq!(stream.status(), &ExecutionStatus::Completed); +} + +// ============================================================================ +// MultiNodeStreamManager Tests +// ============================================================================ + +#[tokio::test] +async fn test_manager_empty() { + let manager = MultiNodeStreamManager::new(); + + assert_eq!(manager.total_count(), 0); + assert_eq!(manager.completed_count(), 0); + assert_eq!(manager.failed_count(), 0); + assert!( + !manager.all_complete(), + "Empty manager should not be 'all complete'" + ); +} + +#[tokio::test] +async fn test_manager_add_streams() { + let mut manager = MultiNodeStreamManager::new(); + + for i in 1..=5 { + let node = Node::new(format!("host{i}"), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + } + + assert_eq!(manager.total_count(), 5); + assert_eq!(manager.streams().len(), 5); +} + +#[tokio::test] +async fn test_manager_poll_all() { + let mut manager = MultiNodeStreamManager::new(); + + let node1 = Node::new("host1".to_string(), 22, "user".to_string()); + let (tx1, rx1) = mpsc::channel::(100); + manager.add_stream(node1, rx1); + + let node2 = Node::new("host2".to_string(), 22, "user".to_string()); + let (tx2, rx2) = mpsc::channel::(100); + manager.add_stream(node2, rx2); + + // Send data to both streams + let data1 = CryptoVec::from(b"output1".to_vec()); + let data2 = CryptoVec::from(b"output2".to_vec()); + tx1.send(CommandOutput::StdOut(data1)).await.unwrap(); + tx2.send(CommandOutput::StdOut(data2)).await.unwrap(); + + // Poll all should receive from both + assert!(manager.poll_all()); + assert_eq!(manager.streams()[0].stdout(), b"output1"); + assert_eq!(manager.streams()[1].stdout(), b"output2"); +} + +#[tokio::test] +async fn test_manager_all_complete() { + let mut manager = MultiNodeStreamManager::new(); + + let node1 = Node::new("host1".to_string(), 22, "user".to_string()); + let (tx1, rx1) = mpsc::channel::(100); + manager.add_stream(node1, rx1); + + let node2 = Node::new("host2".to_string(), 22, "user".to_string()); + let (tx2, rx2) = mpsc::channel::(100); + manager.add_stream(node2, rx2); + + assert!(!manager.all_complete()); + + // Complete both streams + drop(tx1); + drop(tx2); + + manager.poll_all(); + assert!(manager.all_complete()); + assert_eq!(manager.completed_count(), 2); +} + +#[tokio::test] +async fn test_manager_partial_completion() { + let mut manager = MultiNodeStreamManager::new(); + + let node1 = Node::new("host1".to_string(), 22, "user".to_string()); + let (tx1, rx1) = mpsc::channel::(100); + manager.add_stream(node1, rx1); + + let node2 = Node::new("host2".to_string(), 22, "user".to_string()); + let (_tx2, rx2) = mpsc::channel::(100); + manager.add_stream(node2, rx2); + + // Complete only first stream + drop(tx1); + + manager.poll_all(); + assert!(!manager.all_complete()); + assert_eq!(manager.completed_count(), 1); +} + +#[tokio::test] +async fn test_manager_failed_count() { + let mut manager = MultiNodeStreamManager::new(); + + let node1 = Node::new("host1".to_string(), 22, "user".to_string()); + let (tx1, rx1) = mpsc::channel::(100); + manager.add_stream(node1, rx1); + + let node2 = Node::new("host2".to_string(), 22, "user".to_string()); + let (tx2, rx2) = mpsc::channel::(100); + manager.add_stream(node2, rx2); + + // First stream completes successfully + tx1.send(CommandOutput::ExitCode(0)).await.unwrap(); + drop(tx1); + + // Second stream fails + tx2.send(CommandOutput::ExitCode(1)).await.unwrap(); + drop(tx2); + + manager.poll_all(); + assert_eq!( + manager.completed_count(), + 1, + "One should be completed successfully" + ); + assert_eq!(manager.failed_count(), 1, "One should be failed"); + assert!(manager.all_complete()); +} + +#[tokio::test] +async fn test_manager_mutable_streams_access() { + let mut manager = MultiNodeStreamManager::new(); + + let node = Node::new("host".to_string(), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + + // Should be able to mutate streams + let streams = manager.streams_mut(); + streams[0].set_status(ExecutionStatus::Running); + + assert_eq!(manager.streams()[0].status(), &ExecutionStatus::Running); +} + +// ============================================================================ +// Partial Output Handling Tests +// ============================================================================ + +#[tokio::test] +async fn test_partial_output_accumulation() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Simulate partial line output + let chunk1 = CryptoVec::from(b"partial ".to_vec()); + let chunk2 = CryptoVec::from(b"line ".to_vec()); + let chunk3 = CryptoVec::from(b"complete\n".to_vec()); + + tx.send(CommandOutput::StdOut(chunk1)).await.unwrap(); + stream.poll(); + assert_eq!(stream.stdout(), b"partial "); + + tx.send(CommandOutput::StdOut(chunk2)).await.unwrap(); + stream.poll(); + assert_eq!(stream.stdout(), b"partial line "); + + tx.send(CommandOutput::StdOut(chunk3)).await.unwrap(); + stream.poll(); + assert_eq!(stream.stdout(), b"partial line complete\n"); +} + +#[tokio::test] +async fn test_interleaved_stdout_stderr() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send interleaved stdout and stderr + tx.send(CommandOutput::StdOut(CryptoVec::from(b"out1".to_vec()))) + .await + .unwrap(); + tx.send(CommandOutput::StdErr(CryptoVec::from(b"err1".to_vec()))) + .await + .unwrap(); + tx.send(CommandOutput::StdOut(CryptoVec::from(b"out2".to_vec()))) + .await + .unwrap(); + tx.send(CommandOutput::StdErr(CryptoVec::from(b"err2".to_vec()))) + .await + .unwrap(); + + stream.poll(); + + // Should be separated correctly + assert_eq!(stream.stdout(), b"out1out2"); + assert_eq!(stream.stderr(), b"err1err2"); +} + +// ============================================================================ +// Connection Failure Scenario Tests +// ============================================================================ + +#[tokio::test] +async fn test_stream_immediate_close() { + let node = Node::new( + "unreachable.example.com".to_string(), + 22, + "user".to_string(), + ); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Immediately close without any data + drop(tx); + + stream.poll(); + assert!(stream.is_closed()); + assert!(stream.is_complete()); + assert!(stream.stdout().is_empty()); + assert!(stream.stderr().is_empty()); +} + +#[tokio::test] +async fn test_stream_set_status_manually() { + let node = Node::new("host".to_string(), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Manually set failed status (as would happen on connection error) + stream.set_status(ExecutionStatus::Failed("Connection refused".to_string())); + + assert!( + matches!(stream.status(), ExecutionStatus::Failed(msg) if msg.contains("Connection refused")), + "Expected Failed status with connection refused, got {:?}", + stream.status() + ); +} + +#[tokio::test] +async fn test_manager_mixed_connection_states() { + let mut manager = MultiNodeStreamManager::new(); + + // Node 1: Connects and completes + let node1 = Node::new("host1".to_string(), 22, "user".to_string()); + let (tx1, rx1) = mpsc::channel::(100); + manager.add_stream(node1, rx1); + tx1.send(CommandOutput::StdOut(CryptoVec::from(b"success".to_vec()))) + .await + .unwrap(); + tx1.send(CommandOutput::ExitCode(0)).await.unwrap(); + drop(tx1); + + // Node 2: Connection fails immediately + let node2 = Node::new("host2".to_string(), 22, "user".to_string()); + let (tx2, rx2) = mpsc::channel::(100); + manager.add_stream(node2, rx2); + drop(tx2); // Simulate connection failure + + // Node 3: Partially completes then fails + let node3 = Node::new("host3".to_string(), 22, "user".to_string()); + let (tx3, rx3) = mpsc::channel::(100); + manager.add_stream(node3, rx3); + tx3.send(CommandOutput::StdOut(CryptoVec::from(b"partial".to_vec()))) + .await + .unwrap(); + tx3.send(CommandOutput::ExitCode(1)).await.unwrap(); + drop(tx3); + + manager.poll_all(); + + assert_eq!(manager.total_count(), 3); + assert!(manager.all_complete()); + assert_eq!(manager.completed_count(), 2); // node1 and node2 (connection failures are "completed") + assert_eq!(manager.failed_count(), 1); // node3 with exit code 1 +} + +// ============================================================================ +// Performance and Stress Tests +// ============================================================================ + +#[tokio::test] +async fn test_high_throughput_single_stream() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(1000); + let mut stream = NodeStream::new(node, rx); + + // Send many small chunks + let chunk = CryptoVec::from(vec![b'x'; 100]); + for _ in 0..1000 { + tx.send(CommandOutput::StdOut(chunk.clone())).await.unwrap(); + } + + // Poll all data + while stream.poll() { + // Keep polling + } + + assert_eq!( + stream.stdout().len(), + 100 * 1000, + "Should have received all data" + ); +} + +#[tokio::test] +async fn test_many_concurrent_streams() { + let mut manager = MultiNodeStreamManager::new(); + let mut senders = Vec::new(); + + // Create 50 streams + for i in 0..50 { + let node = Node::new(format!("host{i}"), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + senders.push(tx); + } + + // Send data to all streams + for (i, tx) in senders.iter().enumerate() { + let data = CryptoVec::from(format!("output from node {i}").into_bytes()); + tx.send(CommandOutput::StdOut(data)).await.unwrap(); + } + + manager.poll_all(); + + // Verify all streams received their data + for (i, stream) in manager.streams().iter().enumerate() { + let expected = format!("output from node {i}"); + assert_eq!( + stream.stdout(), + expected.as_bytes(), + "Stream {i} should have correct data" + ); + } +} + +#[tokio::test] +async fn test_poll_returns_false_when_no_data() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Poll without sending data should return false + assert!(!stream.poll(), "Poll should return false when no data"); +} + +#[tokio::test] +async fn test_manager_poll_all_returns_correctly() { + let mut manager = MultiNodeStreamManager::new(); + + let node = Node::new("host".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + + // No data yet + assert!(!manager.poll_all(), "Should return false when no data"); + + // Send data + let data = CryptoVec::from(b"data".to_vec()); + tx.send(CommandOutput::StdOut(data)).await.unwrap(); + + assert!(manager.poll_all(), "Should return true when data received"); +} + +// ============================================================================ +// Unicode and Binary Data Tests +// ============================================================================ + +#[tokio::test] +async fn test_stream_with_unicode_output() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send unicode data with actual Korean, Chinese, and Emoji characters + let data = CryptoVec::from( + "Hello, World! Korean: 안녕 Chinese: 你好 Emoji: 🚀🎉" + .as_bytes() + .to_vec(), + ); + tx.send(CommandOutput::StdOut(data)).await.unwrap(); + + stream.poll(); + let output = String::from_utf8_lossy(stream.stdout()); + assert!( + output.contains("Hello, World!"), + "Should contain ASCII text" + ); + assert!(output.contains("안녕"), "Should contain Korean text"); + assert!(output.contains("你好"), "Should contain Chinese text"); + assert!(output.contains("🚀"), "Should contain emoji"); +} + +#[tokio::test] +async fn test_stream_with_binary_output() { + let node = Node::new("localhost".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + let mut stream = NodeStream::new(node, rx); + + // Send binary data with null bytes + let binary_data: Vec = vec![0x00, 0x01, 0x02, 0xFF, 0xFE, 0x00]; + let data = CryptoVec::from(binary_data.clone()); + tx.send(CommandOutput::StdOut(data)).await.unwrap(); + + stream.poll(); + assert_eq!(stream.stdout(), binary_data.as_slice()); +} + +// ============================================================================ +// TuiApp Data Change Detection Tests +// ============================================================================ + +#[tokio::test] +async fn test_app_data_change_detection() { + use bssh::ui::tui::app::TuiApp; + + let mut app = TuiApp::new(); + let mut manager = MultiNodeStreamManager::new(); + + let node = Node::new("host".to_string(), 22, "user".to_string()); + let (tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + + // Initial check - should detect new node + let changed = app.check_data_changes(manager.streams()); + assert!(changed, "Should detect new node"); + + // Check again without changes - should not detect change + let changed = app.check_data_changes(manager.streams()); + assert!(!changed, "Should not detect change when data is same"); + + // Send data + let data = CryptoVec::from(b"new output".to_vec()); + tx.send(CommandOutput::StdOut(data)).await.unwrap(); + manager.poll_all(); + + // Check again - should detect change + let changed = app.check_data_changes(manager.streams()); + assert!(changed, "Should detect data change"); +} diff --git a/tests/tui_event_tests.rs b/tests/tui_event_tests.rs new file mode 100644 index 00000000..17306fec --- /dev/null +++ b/tests/tui_event_tests.rs @@ -0,0 +1,588 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed 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. + +//! TUI Event Handling Tests +//! +//! This module tests keyboard navigation, scroll behavior, follow mode toggle, +//! and node selection across all TUI view modes. + +use bssh::ui::tui::app::{TuiApp, ViewMode}; +use bssh::ui::tui::event::handle_key_event; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Helper to create a key event +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +/// Helper to create a key event with modifiers +fn key_with_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) +} + +// ============================================================================ +// Global Key Tests (work in all modes) +// ============================================================================ + +#[test] +fn test_quit_with_q() { + let mut app = TuiApp::new(); + assert!(!app.should_quit); + + handle_key_event(&mut app, key(KeyCode::Char('q')), 5); + assert!(app.should_quit, "Pressing 'q' should quit the app"); +} + +#[test] +fn test_quit_with_ctrl_c() { + let mut app = TuiApp::new(); + assert!(!app.should_quit); + + handle_key_event( + &mut app, + key_with_mod(KeyCode::Char('c'), KeyModifiers::CONTROL), + 5, + ); + assert!(app.should_quit, "Pressing Ctrl+C should quit the app"); +} + +#[test] +fn test_toggle_help_with_question_mark() { + let mut app = TuiApp::new(); + assert!(!app.show_help); + + handle_key_event(&mut app, key(KeyCode::Char('?')), 5); + assert!(app.show_help, "Pressing '?' should toggle help on"); + + handle_key_event(&mut app, key(KeyCode::Char('?')), 5); + assert!(!app.show_help, "Pressing '?' again should toggle help off"); +} + +#[test] +fn test_esc_closes_help() { + let mut app = TuiApp::new(); + app.show_help = true; + app.show_detail(0, 5); + + handle_key_event(&mut app, key(KeyCode::Esc), 5); + assert!(!app.show_help, "Esc should close help overlay first"); + // View mode should not change when closing help + assert_eq!( + app.view_mode, + ViewMode::Detail(0), + "View should remain unchanged when closing help" + ); +} + +#[test] +fn test_esc_returns_to_summary() { + let mut app = TuiApp::new(); + app.show_detail(2, 5); + + handle_key_event(&mut app, key(KeyCode::Esc), 5); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Esc should return to summary view" + ); +} + +#[test] +fn test_esc_in_summary_stays_in_summary() { + let mut app = TuiApp::new(); + assert_eq!(app.view_mode, ViewMode::Summary); + + handle_key_event(&mut app, key(KeyCode::Esc), 5); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Esc in summary should stay in summary" + ); +} + +// ============================================================================ +// Summary View Key Tests +// ============================================================================ + +#[test] +fn test_summary_number_keys_to_detail() { + let mut app = TuiApp::new(); + let num_nodes = 5; + + // Test keys 1-5 + for i in 1..=5 { + app.show_summary(); + let key_char = char::from_digit(i as u32, 10).unwrap(); + handle_key_event(&mut app, key(KeyCode::Char(key_char)), num_nodes); + assert_eq!( + app.view_mode, + ViewMode::Detail(i - 1), + "Key '{key_char}' should switch to detail view for node {i}" + ); + } +} + +#[test] +fn test_summary_number_key_invalid_node() { + let mut app = TuiApp::new(); + + // Try to select node 9 when only 3 nodes exist + handle_key_event(&mut app, key(KeyCode::Char('9')), 3); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Should not switch to invalid node" + ); +} + +#[test] +fn test_summary_s_for_split_view() { + let mut app = TuiApp::new(); + + handle_key_event(&mut app, key(KeyCode::Char('s')), 5); + assert!( + matches!(&app.view_mode, ViewMode::Split(indices) if indices.len() >= 2 && indices.len() <= 4), + "Should switch to split view with 2-4 nodes, got {:?}", + app.view_mode + ); +} + +#[test] +fn test_summary_s_requires_two_nodes() { + let mut app = TuiApp::new(); + + // With only 1 node, split should not work + handle_key_event(&mut app, key(KeyCode::Char('s')), 1); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Split requires at least 2 nodes" + ); +} + +#[test] +fn test_summary_d_for_diff_view() { + let mut app = TuiApp::new(); + + handle_key_event(&mut app, key(KeyCode::Char('d')), 5); + assert_eq!( + app.view_mode, + ViewMode::Diff(0, 1), + "Should switch to diff view comparing first two nodes" + ); +} + +#[test] +fn test_summary_d_requires_two_nodes() { + let mut app = TuiApp::new(); + + // With only 1 node, diff should not work + handle_key_event(&mut app, key(KeyCode::Char('d')), 1); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Diff requires at least 2 nodes" + ); +} + +// ============================================================================ +// Detail View Key Tests +// ============================================================================ + +#[test] +fn test_detail_scroll_up() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + app.set_scroll(0, 10); + + handle_key_event(&mut app, key(KeyCode::Up), 5); + assert_eq!(app.get_scroll(0), 9, "Up arrow should scroll up by 1"); + assert!(!app.follow_mode, "Scrolling should disable follow mode"); +} + +#[test] +fn test_detail_scroll_down() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + app.set_scroll(0, 10); + + handle_key_event(&mut app, key(KeyCode::Down), 5); + assert_eq!(app.get_scroll(0), 11, "Down arrow should scroll down by 1"); +} + +#[test] +fn test_detail_page_up() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + app.set_scroll(0, 20); + + handle_key_event(&mut app, key(KeyCode::PageUp), 5); + assert_eq!(app.get_scroll(0), 10, "PageUp should scroll up by 10"); +} + +#[test] +fn test_detail_page_down() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + app.set_scroll(0, 10); + + handle_key_event(&mut app, key(KeyCode::PageDown), 5); + assert_eq!(app.get_scroll(0), 20, "PageDown should scroll down by 10"); +} + +#[test] +fn test_detail_home_key() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + app.set_scroll(0, 100); + + handle_key_event(&mut app, key(KeyCode::Home), 5); + assert_eq!(app.get_scroll(0), 0, "Home should scroll to top"); + assert!(!app.follow_mode, "Home should disable follow mode"); +} + +#[test] +fn test_detail_end_key() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + app.follow_mode = false; + + handle_key_event(&mut app, key(KeyCode::End), 5); + // End sets scroll to MAX and re-enables follow mode + assert!(app.follow_mode, "End should enable follow mode"); +} + +#[test] +fn test_detail_next_node_right_arrow() { + let mut app = TuiApp::new(); + app.show_detail(1, 5); + + handle_key_event(&mut app, key(KeyCode::Right), 5); + assert_eq!( + app.view_mode, + ViewMode::Detail(2), + "Right arrow should go to next node" + ); +} + +#[test] +fn test_detail_prev_node_left_arrow() { + let mut app = TuiApp::new(); + app.show_detail(2, 5); + + handle_key_event(&mut app, key(KeyCode::Left), 5); + assert_eq!( + app.view_mode, + ViewMode::Detail(1), + "Left arrow should go to previous node" + ); +} + +#[test] +fn test_detail_node_wrap_around_next() { + let mut app = TuiApp::new(); + app.show_detail(4, 5); // Last node (0-indexed) + + handle_key_event(&mut app, key(KeyCode::Right), 5); + assert_eq!( + app.view_mode, + ViewMode::Detail(0), + "Should wrap to first node" + ); +} + +#[test] +fn test_detail_node_wrap_around_prev() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); // First node + + handle_key_event(&mut app, key(KeyCode::Left), 5); + assert_eq!( + app.view_mode, + ViewMode::Detail(4), + "Should wrap to last node" + ); +} + +#[test] +fn test_detail_toggle_follow_mode() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + let initial_follow = app.follow_mode; + + handle_key_event(&mut app, key(KeyCode::Char('f')), 5); + assert_eq!( + app.follow_mode, !initial_follow, + "Pressing 'f' should toggle follow mode" + ); + + handle_key_event(&mut app, key(KeyCode::Char('f')), 5); + assert_eq!( + app.follow_mode, initial_follow, + "Pressing 'f' again should restore original state" + ); +} + +#[test] +fn test_detail_number_keys_jump() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + + handle_key_event(&mut app, key(KeyCode::Char('3')), 5); + assert_eq!( + app.view_mode, + ViewMode::Detail(2), + "Number key should jump to that node in detail view" + ); +} + +// ============================================================================ +// Split View Key Tests +// ============================================================================ + +#[test] +fn test_split_number_keys_focus() { + let mut app = TuiApp::new(); + app.show_split(vec![0, 1, 2, 3], 5); + + // Pressing '2' should focus on node 2 (index 1) + handle_key_event(&mut app, key(KeyCode::Char('2')), 5); + assert_eq!( + app.view_mode, + ViewMode::Detail(1), + "Number key in split view should focus on that node" + ); +} + +#[test] +fn test_split_esc_to_summary() { + let mut app = TuiApp::new(); + app.show_split(vec![0, 1], 5); + + handle_key_event(&mut app, key(KeyCode::Esc), 5); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Esc should return to summary" + ); +} + +// ============================================================================ +// Diff View Key Tests +// ============================================================================ + +#[test] +fn test_diff_esc_to_summary() { + let mut app = TuiApp::new(); + app.show_diff(0, 1, 5); + + handle_key_event(&mut app, key(KeyCode::Esc), 5); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Esc should return to summary from diff view" + ); +} + +// Note: Synchronized scrolling in diff view is marked as TODO in the source code +#[test] +fn test_diff_arrow_keys_exist() { + let mut app = TuiApp::new(); + app.show_diff(0, 1, 5); + + // These should not panic (even if not fully implemented) + handle_key_event(&mut app, key(KeyCode::Up), 5); + handle_key_event(&mut app, key(KeyCode::Down), 5); + + assert_eq!( + app.view_mode, + ViewMode::Diff(0, 1), + "Should still be in diff view after arrow keys" + ); +} + +// ============================================================================ +// Edge Cases +// ============================================================================ + +#[test] +fn test_scroll_at_zero_cannot_go_negative() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + app.set_scroll(0, 0); + + handle_key_event(&mut app, key(KeyCode::Up), 5); + assert_eq!(app.get_scroll(0), 0, "Scroll should not go negative"); + + handle_key_event(&mut app, key(KeyCode::PageUp), 5); + assert_eq!( + app.get_scroll(0), + 0, + "Scroll should not go negative with PageUp" + ); +} + +#[test] +fn test_single_node_navigation() { + let mut app = TuiApp::new(); + app.show_detail(0, 1); // Only 1 node + + handle_key_event(&mut app, key(KeyCode::Right), 1); + assert_eq!( + app.view_mode, + ViewMode::Detail(0), + "With single node, right arrow should wrap to same node" + ); + + handle_key_event(&mut app, key(KeyCode::Left), 1); + assert_eq!( + app.view_mode, + ViewMode::Detail(0), + "With single node, left arrow should wrap to same node" + ); +} + +#[test] +fn test_quit_from_any_view_mode() { + let view_modes = [ + ViewMode::Summary, + ViewMode::Detail(0), + ViewMode::Split(vec![0, 1]), + ViewMode::Diff(0, 1), + ]; + + for initial_mode in &view_modes { + let mut app = TuiApp::new(); + match initial_mode { + ViewMode::Summary => {} + ViewMode::Detail(idx) => app.show_detail(*idx, 5), + ViewMode::Split(indices) => app.show_split(indices.clone(), 5), + ViewMode::Diff(a, b) => app.show_diff(*a, *b, 5), + } + + handle_key_event(&mut app, key(KeyCode::Char('q')), 5); + assert!( + app.should_quit, + "Should be able to quit from {:?}", + initial_mode + ); + } +} + +#[test] +fn test_help_text_varies_by_mode() { + let mut app = TuiApp::new(); + + // Summary mode help + let summary_help = app.get_help_text(); + assert!( + summary_help.iter().any(|(k, _)| *k == "1-9"), + "Summary help should mention number keys for detail" + ); + + // Detail mode help + app.show_detail(0, 5); + let detail_help = app.get_help_text(); + assert!( + detail_help.iter().any(|(k, _)| k.contains("Scroll") + || k.contains("up") + || k.contains("down") + || *k == "f"), + "Detail help should mention scroll or follow" + ); + + // Split mode help + app.show_split(vec![0, 1], 5); + let split_help = app.get_help_text(); + assert!( + split_help.iter().any(|(k, _)| k.contains("1-4")), + "Split help should mention focus keys" + ); +} + +#[test] +fn test_needs_redraw_after_key_events() { + let mut app = TuiApp::new(); + app.should_redraw(); // Clear initial flag + + // Any view change should set needs_redraw + app.show_detail(0, 5); + assert!(app.needs_redraw, "View change should set needs_redraw"); + + app.should_redraw(); // Clear + app.toggle_follow(); + assert!(app.needs_redraw, "Toggle follow should set needs_redraw"); + + app.should_redraw(); // Clear + app.toggle_help(); + assert!(app.needs_redraw, "Toggle help should set needs_redraw"); +} + +#[test] +fn test_scroll_disables_follow_mode() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + app.follow_mode = true; + app.set_scroll(0, 10); + + // Manual scroll operations should disable follow mode + app.scroll_up(1); + assert!(!app.follow_mode, "Scroll up should disable follow mode"); + + app.follow_mode = true; + app.scroll_down(1, 100); + assert!(!app.follow_mode, "Scroll down should disable follow mode"); +} + +// ============================================================================ +// Stress Tests +// ============================================================================ + +#[test] +fn test_rapid_key_presses() { + let mut app = TuiApp::new(); + + // Simulate rapid key presses + for _ in 0..100 { + handle_key_event(&mut app, key(KeyCode::Down), 5); + handle_key_event(&mut app, key(KeyCode::Up), 5); + handle_key_event(&mut app, key(KeyCode::Right), 5); + handle_key_event(&mut app, key(KeyCode::Left), 5); + } + + // Should not panic and should be in a valid state + match app.view_mode { + ViewMode::Summary | ViewMode::Detail(_) | ViewMode::Split(_) | ViewMode::Diff(_, _) => {} + } +} + +#[test] +fn test_many_view_transitions() { + let mut app = TuiApp::new(); + + for _ in 0..50 { + handle_key_event(&mut app, key(KeyCode::Char('1')), 5); + handle_key_event(&mut app, key(KeyCode::Esc), 5); + handle_key_event(&mut app, key(KeyCode::Char('s')), 5); + handle_key_event(&mut app, key(KeyCode::Esc), 5); + handle_key_event(&mut app, key(KeyCode::Char('d')), 5); + handle_key_event(&mut app, key(KeyCode::Esc), 5); + } + + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Should end in summary view after transitions" + ); +} diff --git a/tests/tui_snapshot_tests.rs b/tests/tui_snapshot_tests.rs new file mode 100644 index 00000000..de9d74c1 --- /dev/null +++ b/tests/tui_snapshot_tests.rs @@ -0,0 +1,621 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed 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. + +//! TUI Snapshot Tests +//! +//! This module tests TUI rendering using ratatui's TestBackend for deterministic output. +//! Tests cover all view modes: Summary, Detail, Split, and Diff views. + +use bssh::executor::{MultiNodeStreamManager, NodeStream}; +use bssh::node::Node; +use bssh::ssh::tokio_client::CommandOutput; +use bssh::ui::tui::app::{TuiApp, ViewMode}; +use ratatui::{backend::TestBackend, buffer::Buffer, Terminal}; +use tokio::sync::mpsc; + +/// Helper to convert buffer to a displayable string for snapshot comparison +fn buffer_to_string(buffer: &Buffer) -> String { + let area = buffer.area; + let mut lines = Vec::new(); + + for y in 0..area.height { + let mut line = String::new(); + for x in 0..area.width { + let cell = buffer.cell((x, y)).unwrap(); + line.push_str(cell.symbol()); + } + // Trim trailing whitespace but preserve structure + let trimmed = line.trim_end(); + lines.push(trimmed.to_string()); + } + + // Remove trailing empty lines + while lines.last().map(|l| l.is_empty()).unwrap_or(false) { + lines.pop(); + } + + lines.join("\n") +} + +/// Create a simple test manager for basic tests +fn create_simple_test_manager() -> MultiNodeStreamManager { + let mut manager = MultiNodeStreamManager::new(); + + // Create nodes with receivers + let node1 = Node::new("host1.example.com".to_string(), 22, "user1".to_string()); + let (_tx1, rx1) = mpsc::channel::(100); + manager.add_stream(node1, rx1); + + let node2 = Node::new("host2.example.com".to_string(), 22, "user2".to_string()); + let (_tx2, rx2) = mpsc::channel::(100); + manager.add_stream(node2, rx2); + + let node3 = Node::new("host3.example.com".to_string(), 22, "user3".to_string()); + let (_tx3, rx3) = mpsc::channel::(100); + manager.add_stream(node3, rx3); + + manager +} + +// ============================================================================ +// Summary View Tests +// ============================================================================ + +#[test] +fn test_summary_view_basic_rendering() { + // Test that summary view renders correctly with default terminal size + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let manager = create_simple_test_manager(); + let _app = TuiApp::new(); + + terminal + .draw(|f| { + bssh::ui::tui::views::summary::render(f, &manager, "test-cluster", "echo hello", false); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + // Verify basic structure - header should contain cluster name + let output = buffer_to_string(buffer); + assert!( + output.contains("test-cluster"), + "Summary view should show cluster name" + ); +} + +#[test] +fn test_summary_view_with_node_states() { + // Test summary view with various node states + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let manager = create_simple_test_manager(); + let _app = TuiApp::new(); + + terminal + .draw(|f| { + bssh::ui::tui::views::summary::render(f, &manager, "production", "apt update", false); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show node labels + assert!(output.contains("[1]"), "Should show node 1 label"); + assert!(output.contains("[2]"), "Should show node 2 label"); + assert!(output.contains("[3]"), "Should show node 3 label"); +} + +#[test] +fn test_summary_view_all_tasks_completed() { + // Test summary view when all tasks are completed + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let manager = create_simple_test_manager(); + + terminal + .draw(|f| { + bssh::ui::tui::views::summary::render( + f, + &manager, + "test-cluster", + "echo done", + true, // all_tasks_completed = true + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show completion message (check for key parts since Unicode rendering may vary) + // The footer shows: "All tasks completed" (may be truncated based on terminal width) + assert!( + output.contains("All tasks complete") || output.contains("tasks complete"), + "Should show completion message when all tasks done. Got: {output}" + ); +} + +#[test] +fn test_summary_view_small_terminal() { + // Test summary view at minimum supported terminal size + let backend = TestBackend::new(40, 10); + let mut terminal = Terminal::new(backend).unwrap(); + + let manager = create_simple_test_manager(); + + terminal + .draw(|f| { + bssh::ui::tui::views::summary::render(f, &manager, "cluster", "cmd", false); + }) + .unwrap(); + + // Should not panic with small terminal + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + assert!( + !output.is_empty(), + "Should render something in small terminal" + ); +} + +#[test] +fn test_summary_view_large_terminal() { + // Test summary view at large terminal size + let backend = TestBackend::new(200, 50); + let mut terminal = Terminal::new(backend).unwrap(); + + let manager = create_simple_test_manager(); + + terminal + .draw(|f| { + bssh::ui::tui::views::summary::render( + f, + &manager, + "large-cluster", + "complex command", + false, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + assert!( + output.contains("large-cluster"), + "Should render correctly in large terminal" + ); +} + +// ============================================================================ +// Detail View Tests +// ============================================================================ + +#[test] +fn test_detail_view_basic_rendering() { + // Test detail view for a single node + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let node = Node::new( + "test-host.example.com".to_string(), + 22, + "testuser".to_string(), + ); + let (_tx, rx) = mpsc::channel::(100); + let stream = NodeStream::new(node, rx); + + terminal + .draw(|f| { + bssh::ui::tui::views::detail::render(f, &stream, 0, 0, false, false); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show node information + assert!( + output.contains("test-host.example.com"), + "Should show node hostname" + ); + assert!(output.contains("[1]"), "Should show node index (1-based)"); +} + +#[test] +fn test_detail_view_with_follow_mode() { + // Test detail view with follow mode enabled + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let node = Node::new("host.example.com".to_string(), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + let stream = NodeStream::new(node, rx); + + terminal + .draw(|f| { + bssh::ui::tui::views::detail::render( + f, &stream, 0, 0, true, // follow_mode = true + false, + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show follow indicator + assert!( + output.contains("FOLLOW"), + "Should show FOLLOW indicator when follow mode is on" + ); +} + +#[test] +fn test_detail_view_no_output() { + // Test detail view when there's no output yet + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let node = Node::new("empty-host.example.com".to_string(), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + let stream = NodeStream::new(node, rx); + + terminal + .draw(|f| { + bssh::ui::tui::views::detail::render(f, &stream, 0, 0, false, false); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show placeholder for empty output + assert!( + output.contains("no output"), + "Should show 'no output' placeholder" + ); +} + +#[test] +fn test_detail_view_all_tasks_completed() { + // Test detail view when all tasks are completed + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let node = Node::new( + "completed-host.example.com".to_string(), + 22, + "user".to_string(), + ); + let (_tx, rx) = mpsc::channel::(100); + let stream = NodeStream::new(node, rx); + + terminal + .draw(|f| { + bssh::ui::tui::views::detail::render( + f, &stream, 0, 0, false, true, // all_tasks_completed = true + ); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show completion indicator (check for key parts since Unicode rendering may vary) + // Text may be truncated based on terminal width + assert!( + output.contains("All task") || output.contains("task"), + "Should show completion message. Got: {output}" + ); +} + +// ============================================================================ +// Split View Tests +// ============================================================================ + +#[test] +fn test_split_view_two_nodes() { + // Test split view with exactly 2 nodes + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let manager = create_simple_test_manager(); + let indices = vec![0, 1]; + + terminal + .draw(|f| { + bssh::ui::tui::views::split::render(f, &manager, &indices); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show both nodes + assert!(output.contains("[1]"), "Should show node 1"); + assert!(output.contains("[2]"), "Should show node 2"); +} + +#[test] +fn test_split_view_four_nodes() { + // Test split view with 4 nodes (2x2 grid) + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).unwrap(); + + // Create manager with 4 nodes + let mut manager = MultiNodeStreamManager::new(); + for i in 1..=4 { + let node = Node::new(format!("host{i}.example.com"), 22, format!("user{i}")); + let (_tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + } + + let indices = vec![0, 1, 2, 3]; + + terminal + .draw(|f| { + bssh::ui::tui::views::split::render(f, &manager, &indices); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show all 4 nodes + assert!(output.contains("[1]"), "Should show node 1"); + assert!(output.contains("[2]"), "Should show node 2"); + assert!(output.contains("[3]"), "Should show node 3"); + assert!(output.contains("[4]"), "Should show node 4"); +} + +#[test] +fn test_split_view_single_node_error() { + // Test split view with only 1 node (should show error) + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let mut manager = MultiNodeStreamManager::new(); + let node = Node::new("single.example.com".to_string(), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + manager.add_stream(node, rx); + + let indices = vec![0]; + + terminal + .draw(|f| { + bssh::ui::tui::views::split::render(f, &manager, &indices); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show error about requiring at least 2 nodes + assert!( + output.contains("at least 2 nodes"), + "Should show error message for single node" + ); +} + +// ============================================================================ +// Diff View Tests +// ============================================================================ + +#[test] +fn test_diff_view_two_nodes() { + // Test diff view comparing two nodes + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + + let node_a = Node::new("node-a.example.com".to_string(), 22, "user".to_string()); + let (_tx_a, rx_a) = mpsc::channel::(100); + let stream_a = NodeStream::new(node_a, rx_a); + + let node_b = Node::new("node-b.example.com".to_string(), 22, "user".to_string()); + let (_tx_b, rx_b) = mpsc::channel::(100); + let stream_b = NodeStream::new(node_b, rx_b); + + terminal + .draw(|f| { + bssh::ui::tui::views::diff::render(f, &stream_a, &stream_b, 0, 1, 0); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show diff header + assert!(output.contains("Diff View"), "Should show diff view header"); + // Should show both nodes + assert!(output.contains("[1]"), "Should show node 1 label"); + assert!(output.contains("[2]"), "Should show node 2 label"); +} + +#[test] +fn test_diff_view_no_output() { + // Test diff view with empty outputs + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + + let node_a = Node::new("empty-a.example.com".to_string(), 22, "user".to_string()); + let (_tx_a, rx_a) = mpsc::channel::(100); + let stream_a = NodeStream::new(node_a, rx_a); + + let node_b = Node::new("empty-b.example.com".to_string(), 22, "user".to_string()); + let (_tx_b, rx_b) = mpsc::channel::(100); + let stream_b = NodeStream::new(node_b, rx_b); + + terminal + .draw(|f| { + bssh::ui::tui::views::diff::render(f, &stream_a, &stream_b, 0, 1, 0); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let output = buffer_to_string(buffer); + + // Should show placeholder for empty outputs + assert!( + output.contains("no output"), + "Should show 'no output' for empty streams" + ); +} + +// ============================================================================ +// Edge Cases +// ============================================================================ + +#[test] +fn test_render_with_unicode_content() { + // Test rendering with unicode characters in hostname + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + let node = Node::new("test-host".to_string(), 22, "user".to_string()); + let (_tx, rx) = mpsc::channel::(100); + let stream = NodeStream::new(node, rx); + + terminal + .draw(|f| { + bssh::ui::tui::views::detail::render(f, &stream, 0, 0, false, false); + }) + .unwrap(); + + // Should not panic with unicode + let buffer = terminal.backend().buffer(); + assert!(buffer.area.width > 0, "Buffer should have width"); +} + +#[test] +fn test_render_minimum_dimensions() { + // Test rendering at absolute minimum dimensions + let backend = TestBackend::new(20, 5); + let mut terminal = Terminal::new(backend).unwrap(); + + let node = Node::new("h".to_string(), 22, "u".to_string()); + let (_tx, rx) = mpsc::channel::(100); + let stream = NodeStream::new(node, rx); + + // This should not panic + terminal + .draw(|f| { + bssh::ui::tui::views::detail::render(f, &stream, 0, 0, false, false); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + assert!( + buffer.area.height > 0, + "Should render even with minimal dimensions" + ); +} + +// ============================================================================ +// TuiApp State Tests +// ============================================================================ + +#[test] +fn test_tui_app_view_mode_transitions() { + let mut app = TuiApp::new(); + + // Initial state + assert_eq!(app.view_mode, ViewMode::Summary); + + // Transition to detail view + app.show_detail(0, 5); + assert_eq!(app.view_mode, ViewMode::Detail(0)); + + // Transition to split view + app.show_split(vec![0, 1, 2], 5); + assert_eq!(app.view_mode, ViewMode::Split(vec![0, 1, 2])); + + // Transition to diff view + app.show_diff(0, 1, 5); + assert_eq!(app.view_mode, ViewMode::Diff(0, 1)); + + // Back to summary + app.show_summary(); + assert_eq!(app.view_mode, ViewMode::Summary); +} + +#[test] +fn test_tui_app_invalid_transitions() { + let mut app = TuiApp::new(); + + // Try to show detail for invalid index + app.show_detail(10, 5); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Should not change to invalid node" + ); + + // Try to show split with only one valid index + app.show_split(vec![0], 5); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Should not change to split with one node" + ); + + // Try to show diff with same node + app.show_diff(2, 2, 5); + assert_eq!( + app.view_mode, + ViewMode::Summary, + "Should not diff node with itself" + ); +} + +#[test] +fn test_tui_app_needs_redraw_flag() { + let mut app = TuiApp::new(); + + // Initial state needs redraw + assert!(app.needs_redraw); + assert!(app.should_redraw()); + // After checking, flag should be reset + assert!(!app.needs_redraw); + + // View changes should set flag + app.show_detail(0, 5); + assert!(app.needs_redraw); + app.should_redraw(); + + // Toggle follow should set flag + app.toggle_follow(); + assert!(app.needs_redraw); +} + +#[test] +fn test_tui_app_scroll_position_limits() { + let mut app = TuiApp::new(); + app.show_detail(0, 5); + + // Test scroll up at zero (should stay at 0) + app.set_scroll(0, 0); + app.scroll_up(10); + assert_eq!(app.get_scroll(0), 0, "Scroll should not go below 0"); + + // Test scroll down with max limit + app.set_scroll(0, 0); + app.scroll_down(100, 50); + assert_eq!(app.get_scroll(0), 50, "Scroll should be limited to max"); +}