diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index ba7a2315..6f2abe2e 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -1171,12 +1171,41 @@ impl MultiPodApp { } fn handle_mouse_event(&mut self, event: MouseEvent) -> bool { - if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { - return false; - } if self.panel_diagnostic_open { return false; } + match event.kind { + MouseEventKind::ScrollDown => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "panel", + "mouse_wheel", + serde_json::json!({ + "column": event.column, + "row": event.row, + "direction": "down", + }), + ); + self.select_next(); + return true; + } + MouseEventKind::ScrollUp => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "panel", + "mouse_wheel", + serde_json::json!({ + "column": event.column, + "row": event.row, + "direction": "up", + }), + ); + self.select_prev(); + return true; + } + MouseEventKind::Down(MouseButton::Left) => {} + _ => return false, + } let Some(key) = self .row_hit_boxes .iter() diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 40b2ebdb..07e1b63b 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -15,6 +15,8 @@ use crossterm::event::{ }; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::{Command, execute}; +#[cfg(feature = "e2e-test")] +use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; use protocol::{Method, PodStatus}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; @@ -75,6 +77,15 @@ pub(crate) async fn run_pod_name( socket_override: Option, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { + #[cfg(feature = "e2e-test")] + if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() { + let mut terminal = enter_fullscreen()?; + terminal.clear()?; + let result = run_e2e_rewind_fixture(&mut terminal, pod_name).await; + let _ = leave_fullscreen(&mut terminal); + return result; + } + if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await { let mut terminal = enter_fullscreen()?; run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?; @@ -248,6 +259,16 @@ pub(crate) async fn run_spawn( profile: Option, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { + #[cfg(feature = "e2e-test")] + if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() { + let mut terminal = enter_fullscreen()?; + terminal.clear()?; + let fixture_pod_name = pod_name.unwrap_or_else(|| "e2e-rewind".to_string()); + let result = run_e2e_rewind_fixture(&mut terminal, fixture_pod_name).await; + let _ = leave_fullscreen(&mut terminal); + return result; + } + let ready = match spawn::run(resume_from, pod_name, profile, runtime_command.clone()).await? { SpawnOutcome::Ready(r) => r, SpawnOutcome::Cancelled => return Ok(()), @@ -388,6 +409,181 @@ fn read_terminal_events(stop: Arc, tx: mpsc::UnboundedSender Result<(), Box> { + let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let mut app = App::new_with_persistent_input_history(pod_name.clone(), &workspace_root); + app.connected = true; + app.handle_pod_event(Event::Snapshot { + entries: Vec::new(), + status: PodStatus::Idle, + greeting: Greeting { + pod_name: pod_name.clone(), + cwd: workspace_root.display().to_string(), + provider: "e2e-fixture".to_string(), + model: "canned".to_string(), + scope_summary: "isolated e2e rewind fixture".to_string(), + tools: Vec::new(), + context_window: 0, + context_tokens: 0, + }, + }); + + let (_reader, mut term_rx) = TerminalEventReader::spawn()?; + let target_id = RewindTargetId { + segment_id: uuid::Uuid::from_u128(1), + user_input_entry_index: 1, + }; + let mut rewind_submit_count = 0usize; + let mut pending_apply: Option = None; + let apply_delay = Duration::from_millis(400); + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "single_pod", + "rewind_fixture_ready", + serde_json::json!({ "pod": pod_name.clone() }), + ); + terminal.draw(|frame| ui::draw(frame, &mut app))?; + + loop { + let wait = pending_apply.map(|submitted_at| { + apply_delay + .checked_sub(submitted_at.elapsed()) + .unwrap_or(Duration::ZERO) + }); + let input = match wait { + Some(Duration::ZERO) => E2eRewindInput::Tick, + Some(timeout) => match tokio::time::timeout(timeout, term_rx.recv()).await { + Ok(Some(Ok(event))) => E2eRewindInput::Terminal(event), + Ok(Some(Err(err))) => return Err(Box::new(err)), + Ok(None) => E2eRewindInput::TerminalClosed, + Err(_) => E2eRewindInput::Tick, + }, + None => match term_rx.recv().await { + Some(Ok(event)) => E2eRewindInput::Terminal(event), + Some(Err(err)) => return Err(Box::new(err)), + None => E2eRewindInput::TerminalClosed, + }, + }; + + let mut needs_draw = false; + match input { + E2eRewindInput::Terminal(TermEvent::Key(key)) => { + let duplicate_enter_pending = matches!(key.code, KeyCode::Enter) + && app + .rewind_picker + .as_ref() + .map(|picker| picker.applying) + .unwrap_or(false); + if let Some(method) = handle_key(&mut app, key) { + match method { + Method::ListRewindTargets => { + app.handle_pod_event(Event::RewindTargets { + head_entries: 3, + targets: vec![RewindTarget { + id: target_id.clone(), + expected_head_entries: 3, + truncate_entries: 1, + turn_index: 1, + timestamp_ms: Some(1), + preview: "revise the plan".to_string(), + eligible: true, + disabled_reason: None, + warning: None, + }], + }); + crate::e2e_observer::emit( + "single_pod", + "rewind_picker_opened", + serde_json::json!({ + "targets": 1, + "selected_preview": "revise the plan", + }), + ); + } + Method::RewindTo { + target, + expected_head_entries, + } => { + rewind_submit_count += 1; + pending_apply = Some(std::time::Instant::now()); + crate::e2e_observer::emit( + "single_pod", + "rewind_submit_sent", + serde_json::json!({ + "segment_id": target.segment_id.to_string(), + "user_input_entry_index": target.user_input_entry_index, + "expected_head_entries": expected_head_entries, + "submit_count": rewind_submit_count, + }), + ); + } + _ => {} + } + } else if duplicate_enter_pending { + crate::e2e_observer::emit( + "single_pod", + "rewind_duplicate_enter_suppressed", + serde_json::json!({ "submit_count": rewind_submit_count }), + ); + } + needs_draw = true; + } + E2eRewindInput::Terminal(TermEvent::Mouse(_)) + | E2eRewindInput::Terminal(TermEvent::Resize(_, _)) + | E2eRewindInput::Tick => { + needs_draw = true; + } + E2eRewindInput::TerminalClosed => break, + E2eRewindInput::Terminal(_) => {} + } + + if let Some(submitted_at) = pending_apply { + if submitted_at.elapsed() >= apply_delay { + app.handle_pod_event(Event::RewindApplied { + entries: Vec::new(), + input: vec![Segment::text("revise the plan")], + summary: RewindSummary { + truncated_to_entries: 1, + discarded_entries: 2, + tool_side_effect_warning: false, + }, + }); + pending_apply = None; + let composer_text = Segment::flatten_to_text(&app.input.submit_segments()); + crate::e2e_observer::emit( + "single_pod", + "rewind_applied", + serde_json::json!({ + "composer_text": composer_text, + "submit_count": rewind_submit_count, + }), + ); + needs_draw = true; + } + } + + if app.quit { + break; + } + if needs_draw { + terminal.draw(|frame| ui::draw(frame, &mut app))?; + } + } + + Ok(()) +} + +#[cfg(feature = "e2e-test")] +enum E2eRewindInput { + Terminal(TermEvent), + TerminalClosed, + Tick, +} + enum LoopInput

{ Terminal(TerminalEventResult), Pod(Option

), diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index dc5b0690..11b85a16 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -4,6 +4,7 @@ version = "0.0.0" edition.workspace = true license.workspace = true publish = false +autotests = false [features] default = [] @@ -19,3 +20,8 @@ tempfile.workspace = true name = "panel" path = "tests/panel.rs" required-features = ["e2e"] + +[[test]] +name = "rewind" +path = "tests/rewind.rs" +required-features = ["e2e"] diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index 9157e586..b3dc4c35 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -104,7 +104,7 @@ fn fixture_setup_env_policy() -> EnvPolicy { ) } -fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy { +fn tui_env_policy(include_hold_background_task: bool, include_rewind_fixture: bool) -> EnvPolicy { let mut allowlist = vec![ "HOME", "XDG_DATA_HOME", @@ -118,12 +118,19 @@ fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy { if include_hold_background_task { allowlist.push("YOI_TUI_TEST_HOLD_BACKGROUND_TASK"); } + if include_rewind_fixture { + allowlist.push("YOI_TUI_TEST_REWIND_FIXTURE"); + } env_policy( &allowlist, - "tested yoi panel subprocess uses env_clear and receives only fixture HOME, XDG data/state/config/runtime dirs, terminal/test-observer variables, and the explicit runtime binary override", + "tested yoi TUI subprocess uses env_clear and receives only fixture HOME, XDG data/state/config/runtime dirs, terminal/test-observer variables, explicit e2e fixture toggles, and the explicit runtime binary override", ) } +fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy { + tui_env_policy(include_hold_background_task, false) +} + fn tested_yoi_env_policy_overview() -> TestedYoiEnvPolicy { TestedYoiEnvPolicy { fixture_setup: fixture_setup_env_policy(), @@ -150,6 +157,9 @@ pub enum HarnessError { MouseCaptureNotEnabled { artifacts: PanelArtifacts, }, + FullDragMouseCaptureEnabled { + artifacts: PanelArtifacts, + }, Protocol(String), } @@ -184,6 +194,11 @@ impl std::fmt::Display for HarnessError { "terminal mouse capture was not observed before mouse input; artifacts at {}", artifacts.dir.display() ), + Self::FullDragMouseCaptureEnabled { artifacts } => write!( + f, + "forbidden full drag-motion mouse capture (?1002h/?1003h) was observed; artifacts at {}", + artifacts.dir.display() + ), Self::Protocol(message) => write!(f, "protocol error: {message}"), } } @@ -215,6 +230,8 @@ pub struct PanelHarnessConfig { pub fixture_root: PathBuf, pub terminal_size: (u16, u16), pub hold_background_task: Option, + pub rewind_fixture: bool, + pub command_args: Vec, pub artifacts_dir: PathBuf, } @@ -260,6 +277,7 @@ pub struct RowsRendered { pub enum KeyPress { CtrlC, CtrlD, + CtrlR, Enter, Esc, Text(String), @@ -299,11 +317,13 @@ impl PanelHarness { fs::write(&artifacts.events_jsonl, "")?; fs::write(&artifacts.input_log, "")?; fs::write(&artifacts.output_log, "")?; - let env_policy = panel_env_policy(config.hold_background_task.is_some()); + let env_policy = + tui_env_policy(config.hold_background_task.is_some(), config.rewind_fixture); fs::write( &artifacts.run_json, serde_json::to_vec_pretty(&serde_json::json!({ "binary": config.binary, + "args": &config.command_args, "workspace": config.workspace, "home": config.home, "xdg_data_home": config.xdg_data_home, @@ -321,6 +341,7 @@ impl PanelHarness { "rows": config.terminal_size.1, }, "hold_background_task": config.hold_background_task, + "rewind_fixture": config.rewind_fixture, "tested_yoi_env_policy": &env_policy, }))?, )?; @@ -331,9 +352,7 @@ impl PanelHarness { let mut command = Command::new(&config.binary); command - .arg("panel") - .arg("--workspace") - .arg(&config.workspace) + .args(&config.command_args) .env_clear() .env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl) .env("YOI_POD_RUNTIME_COMMAND", &config.binary) @@ -349,6 +368,9 @@ impl PanelHarness { if let Some(task) = &config.hold_background_task { command.env("YOI_TUI_TEST_HOLD_BACKGROUND_TASK", task); } + if config.rewind_fixture { + command.env("YOI_TUI_TEST_REWIND_FIXTURE", "1"); + } let child = command.spawn()?; let output = Arc::new(Mutex::new(Vec::new())); @@ -457,6 +479,58 @@ impl PanelHarness { } } + pub fn assert_no_full_drag_mouse_capture(&mut self) -> Result<()> { + if self.full_drag_mouse_capture_observed() { + self.flush_output_artifact()?; + return Err(HarnessError::FullDragMouseCaptureEnabled { + artifacts: self.artifacts.clone(), + }); + } + Ok(()) + } + + pub fn expect_event( + &mut self, + event_name: &'static str, + timeout: Duration, + ) -> Result { + self.wait_for(event_name, timeout, |event| event.event == event_name) + } + + pub fn count_events(&mut self, event_name: &str) -> Result { + Ok(self + .events()? + .into_iter() + .filter(|event| event.event == event_name) + .count()) + } + + pub fn wait_for_no_additional_events( + &mut self, + event_name: &str, + baseline: usize, + duration: Duration, + ) -> Result<()> { + let start = Instant::now(); + while start.elapsed() < duration { + if let Some(status) = self.child.try_wait()? { + self.flush_output_artifact()?; + return Err(HarnessError::Protocol(format!( + "process exited with {status} while waiting for no additional {event_name} events" + ))); + } + let count = self.count_events(event_name)?; + if count > baseline { + self.flush_output_artifact()?; + return Err(HarnessError::Protocol(format!( + "observed {count} {event_name} events; expected no more than {baseline}" + ))); + } + thread::sleep(Duration::from_millis(20)); + } + Ok(()) + } + pub fn expect_background_task_pending(&mut self, task: &str) -> Result<()> { let start = Instant::now(); loop { @@ -495,10 +569,40 @@ impl PanelHarness { ) } + pub fn wheel_down(&mut self, row: &RenderedPanelRow) -> Result<()> { + self.wheel(row, 65, "down") + } + + pub fn wheel_up(&mut self, row: &RenderedPanelRow) -> Result<()> { + self.wheel(row, 64, "up") + } + + pub fn wheel(&mut self, row: &RenderedPanelRow, sgr_button: u8, label: &str) -> Result<()> { + if !self.mouse_capture_enabled() { + self.flush_output_artifact()?; + return Err(HarnessError::MouseCaptureNotEnabled { + artifacts: self.artifacts.clone(), + }); + } + let x = row.rect.x.saturating_add(1); + let y = row.rect.y; + self.write_input( + &format!("mouse wheel {label} {} at {},{}", row.title, x, y), + format!( + "\u{1b}[<{};{};{}M", + sgr_button, + x.saturating_add(1), + y.saturating_add(1) + ) + .as_bytes(), + ) + } + pub fn press(&mut self, key: KeyPress) -> Result<()> { match key { KeyPress::CtrlC => self.write_input("Ctrl+C", b"\x03"), KeyPress::CtrlD => self.write_input("Ctrl+D", b"\x04"), + KeyPress::CtrlR => self.write_input("Ctrl+R", b"\x12"), KeyPress::Enter => self.write_input("Enter", b"\r"), KeyPress::Esc => self.write_input("Esc", b"\x1b"), KeyPress::Text(text) => self.write_input(&format!("text {text:?}"), text.as_bytes()), @@ -587,6 +691,13 @@ impl PanelHarness { .unwrap_or(false) } + fn full_drag_mouse_capture_observed(&self) -> bool { + self.output + .lock() + .map(|output| output_has_full_drag_mouse_capture(&output)) + .unwrap_or(false) + } + fn flush_output_artifact(&self) -> Result<()> { if let Ok(output) = self.output.lock() { fs::write(&self.artifacts.output_log, &*output)?; @@ -744,6 +855,12 @@ impl FixtureWorkspace { fixture_root: self.root.clone(), terminal_size: (100, 32), hold_background_task: None, + rewind_fixture: false, + command_args: vec![ + "panel".to_string(), + "--workspace".to_string(), + self.workspace.display().to_string(), + ], artifacts_dir: self.artifacts_dir.clone(), } } @@ -758,6 +875,19 @@ impl FixtureWorkspace { config } + pub fn rewind_fixture_config(&self, binary: PathBuf) -> PanelHarnessConfig { + let mut config = self.panel_config(binary); + config.rewind_fixture = true; + config.command_args = vec![ + "--workspace".to_string(), + self.workspace.display().to_string(), + "--pod".to_string(), + "e2e-rewind".to_string(), + ]; + config.artifacts_dir = self.artifacts_dir.join("rewind"); + config + } + pub fn cleanup(mut self) -> Result { self.cleanup_inner(true) } @@ -1169,6 +1299,15 @@ fn output_has_enabled_mouse_capture(output: &[u8]) -> bool { && mouse_mode_enabled(output, b"\x1b[?1006h", b"\x1b[?1006l") } +fn output_has_full_drag_mouse_capture(output: &[u8]) -> bool { + output + .windows(b"\x1b[?1002h".len()) + .any(|window| window == b"\x1b[?1002h") + || output + .windows(b"\x1b[?1003h".len()) + .any(|window| window == b"\x1b[?1003h") +} + fn mouse_mode_enabled(output: &[u8], enable: &[u8], disable: &[u8]) -> bool { let last_enable = last_subsequence_index(output, enable); let last_disable = last_subsequence_index(output, disable); @@ -1253,6 +1392,7 @@ mod tests { "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", "YOI_POD_RUNTIME_COMMAND", ] ); @@ -1266,6 +1406,7 @@ mod tests { "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", + "XDG_RUNTIME_DIR", "TERM", "YOI_TUI_TEST_EVENTS", "YOI_POD_RUNTIME_COMMAND", diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 5ae4cf45..5b094758 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -12,6 +12,7 @@ fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; panel.expect_mouse_capture_enabled()?; + panel.assert_no_full_drag_mouse_capture()?; let rows = panel.wait_for_rows(2)?; assert_no_runtime_or_host_pod_leak( &fixture, @@ -38,6 +39,69 @@ fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result "mouse selection must not dispatch panel actions; artifacts at {}", panel.artifacts().dir.display() ); + panel.assert_no_full_drag_mouse_capture()?; + + panel.press(KeyPress::CtrlC)?; + let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; + assert!(status.success(), "panel should exit cleanly with Ctrl+C"); + drop(panel); + assert_fixture_cleanup(fixture.cleanup()?); + Ok(()) +} + +#[test] +fn panel_mouse_wheel_moves_selection_without_full_drag_capture() -> yoi_e2e::Result<()> { + let binary = yoi_binary()?; + let fixture = FixtureWorkspace::new(&binary)?; + assert_fixture_paths_are_isolated(&fixture); + let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; + + panel.expect_mouse_capture_enabled()?; + panel.assert_no_full_drag_mouse_capture()?; + let rows = panel.wait_for_rows(2)?; + assert_no_runtime_or_host_pod_leak( + &fixture, + &rows.rows, + panel.artifacts().dir.display().to_string().as_str(), + ); + let selected = rows + .selected + .as_ref() + .expect("fixture should render an initially selected row") + .clone(); + let selected_index = rows + .rows + .iter() + .position(|row| row.key == selected) + .expect("selected row should be rendered"); + let target_index = (selected_index + 1).min(rows.rows.len() - 1); + assert_ne!( + selected_index, target_index, + "fixture should render a wheel-selectable next row" + ); + let source = rows.rows[selected_index].clone(); + let target = rows.rows[target_index].clone(); + + let before_events = panel.events()?.len(); + panel.wheel_down(&source)?; + panel.expect_selection(&target.key)?; + panel.assert_no_full_drag_mouse_capture()?; + + let events = panel.events()?; + assert!( + events[before_events..] + .iter() + .any(|event| event.event == "mouse_wheel"), + "wheel movement should be visible in e2e events; artifacts at {}", + panel.artifacts().dir.display() + ); + assert!( + events[before_events..] + .iter() + .all(|event| event.event != "action_requested"), + "wheel selection must not dispatch panel actions; artifacts at {}", + panel.artifacts().dir.display() + ); panel.press(KeyPress::CtrlC)?; let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; diff --git a/tests/e2e/tests/rewind.rs b/tests/e2e/tests/rewind.rs new file mode 100644 index 00000000..386bde93 --- /dev/null +++ b/tests/e2e/tests/rewind.rs @@ -0,0 +1,61 @@ +use std::time::Duration; + +use yoi_e2e::{FixtureWorkspace, KeyPress, PanelHarness, yoi_binary}; + +#[test] +fn single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter() +-> yoi_e2e::Result<()> { + let binary = yoi_binary()?; + let fixture = FixtureWorkspace::new(&binary)?; + let mut tui = PanelHarness::spawn(fixture.rewind_fixture_config(binary))?; + + tui.expect_mouse_capture_enabled()?; + tui.assert_no_full_drag_mouse_capture()?; + tui.expect_event("rewind_fixture_ready", Duration::from_secs(5))?; + + tui.press(KeyPress::CtrlR)?; + tui.expect_event("rewind_picker_opened", Duration::from_secs(5))?; + + tui.press(KeyPress::Enter)?; + tui.expect_event("rewind_submit_sent", Duration::from_secs(5))?; + let submit_count = tui.count_events("rewind_submit_sent")?; + + tui.press(KeyPress::Enter)?; + tui.expect_event("rewind_duplicate_enter_suppressed", Duration::from_secs(5))?; + tui.wait_for_no_additional_events( + "rewind_submit_sent", + submit_count, + Duration::from_millis(250), + )?; + + let applied = tui.expect_event("rewind_applied", Duration::from_secs(5))?; + assert_eq!( + applied + .data + .get("composer_text") + .and_then(serde_json::Value::as_str), + Some("revise the plan"), + "rewind should update the visible composer state without Esc/restart; artifacts at {}", + tui.artifacts().dir.display() + ); + assert_eq!( + tui.count_events("rewind_submit_sent")?, + submit_count, + "pending Enter must not duplicate destructive rewind submissions; artifacts at {}", + tui.artifacts().dir.display() + ); + + tui.press(KeyPress::CtrlD)?; + let status = tui.expect_exit_within(PanelHarness::default_exit_wait())?; + assert!( + status.success(), + "single-pod rewind fixture should exit cleanly" + ); + drop(tui); + let cleanup = fixture.cleanup()?; + assert!( + cleanup.cleanup_success, + "fixture cleanup failed: {cleanup:?}" + ); + Ok(()) +}