test: cover critical tui e2e paths
This commit is contained in:
parent
6cae63fc53
commit
b9f49eee1f
|
|
@ -1171,12 +1171,41 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_mouse_event(&mut self, event: MouseEvent) -> bool {
|
fn handle_mouse_event(&mut self, event: MouseEvent) -> bool {
|
||||||
if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if self.panel_diagnostic_open {
|
if self.panel_diagnostic_open {
|
||||||
return false;
|
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
|
let Some(key) = self
|
||||||
.row_hit_boxes
|
.row_hit_boxes
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ use crossterm::event::{
|
||||||
};
|
};
|
||||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
use crossterm::{Command, execute};
|
use crossterm::{Command, execute};
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
||||||
use protocol::{Method, PodStatus};
|
use protocol::{Method, PodStatus};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
|
@ -75,6 +77,15 @@ pub(crate) async fn run_pod_name(
|
||||||
socket_override: Option<PathBuf>,
|
socket_override: Option<PathBuf>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
#[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 {
|
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_fullscreen()?;
|
||||||
run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?;
|
run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?;
|
||||||
|
|
@ -248,6 +259,16 @@ pub(crate) async fn run_spawn(
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
#[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? {
|
let ready = match spawn::run(resume_from, pod_name, profile, runtime_command.clone()).await? {
|
||||||
SpawnOutcome::Ready(r) => r,
|
SpawnOutcome::Ready(r) => r,
|
||||||
SpawnOutcome::Cancelled => return Ok(()),
|
SpawnOutcome::Cancelled => return Ok(()),
|
||||||
|
|
@ -388,6 +409,181 @@ fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<Termina
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
async fn run_e2e_rewind_fixture(
|
||||||
|
terminal: &mut FullscreenTerminal,
|
||||||
|
pod_name: String,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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<std::time::Instant> = 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<P> {
|
enum LoopInput<P> {
|
||||||
Terminal(TerminalEventResult),
|
Terminal(TerminalEventResult),
|
||||||
Pod(Option<P>),
|
Pod(Option<P>),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ version = "0.0.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
autotests = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|
@ -19,3 +20,8 @@ tempfile.workspace = true
|
||||||
name = "panel"
|
name = "panel"
|
||||||
path = "tests/panel.rs"
|
path = "tests/panel.rs"
|
||||||
required-features = ["e2e"]
|
required-features = ["e2e"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "rewind"
|
||||||
|
path = "tests/rewind.rs"
|
||||||
|
required-features = ["e2e"]
|
||||||
|
|
|
||||||
|
|
@ -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![
|
let mut allowlist = vec![
|
||||||
"HOME",
|
"HOME",
|
||||||
"XDG_DATA_HOME",
|
"XDG_DATA_HOME",
|
||||||
|
|
@ -118,12 +118,19 @@ fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy {
|
||||||
if include_hold_background_task {
|
if include_hold_background_task {
|
||||||
allowlist.push("YOI_TUI_TEST_HOLD_BACKGROUND_TASK");
|
allowlist.push("YOI_TUI_TEST_HOLD_BACKGROUND_TASK");
|
||||||
}
|
}
|
||||||
|
if include_rewind_fixture {
|
||||||
|
allowlist.push("YOI_TUI_TEST_REWIND_FIXTURE");
|
||||||
|
}
|
||||||
env_policy(
|
env_policy(
|
||||||
&allowlist,
|
&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 {
|
fn tested_yoi_env_policy_overview() -> TestedYoiEnvPolicy {
|
||||||
TestedYoiEnvPolicy {
|
TestedYoiEnvPolicy {
|
||||||
fixture_setup: fixture_setup_env_policy(),
|
fixture_setup: fixture_setup_env_policy(),
|
||||||
|
|
@ -150,6 +157,9 @@ pub enum HarnessError {
|
||||||
MouseCaptureNotEnabled {
|
MouseCaptureNotEnabled {
|
||||||
artifacts: PanelArtifacts,
|
artifacts: PanelArtifacts,
|
||||||
},
|
},
|
||||||
|
FullDragMouseCaptureEnabled {
|
||||||
|
artifacts: PanelArtifacts,
|
||||||
|
},
|
||||||
Protocol(String),
|
Protocol(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +194,11 @@ impl std::fmt::Display for HarnessError {
|
||||||
"terminal mouse capture was not observed before mouse input; artifacts at {}",
|
"terminal mouse capture was not observed before mouse input; artifacts at {}",
|
||||||
artifacts.dir.display()
|
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}"),
|
Self::Protocol(message) => write!(f, "protocol error: {message}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -215,6 +230,8 @@ pub struct PanelHarnessConfig {
|
||||||
pub fixture_root: PathBuf,
|
pub fixture_root: PathBuf,
|
||||||
pub terminal_size: (u16, u16),
|
pub terminal_size: (u16, u16),
|
||||||
pub hold_background_task: Option<String>,
|
pub hold_background_task: Option<String>,
|
||||||
|
pub rewind_fixture: bool,
|
||||||
|
pub command_args: Vec<String>,
|
||||||
pub artifacts_dir: PathBuf,
|
pub artifacts_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,6 +277,7 @@ pub struct RowsRendered {
|
||||||
pub enum KeyPress {
|
pub enum KeyPress {
|
||||||
CtrlC,
|
CtrlC,
|
||||||
CtrlD,
|
CtrlD,
|
||||||
|
CtrlR,
|
||||||
Enter,
|
Enter,
|
||||||
Esc,
|
Esc,
|
||||||
Text(String),
|
Text(String),
|
||||||
|
|
@ -299,11 +317,13 @@ impl PanelHarness {
|
||||||
fs::write(&artifacts.events_jsonl, "")?;
|
fs::write(&artifacts.events_jsonl, "")?;
|
||||||
fs::write(&artifacts.input_log, "")?;
|
fs::write(&artifacts.input_log, "")?;
|
||||||
fs::write(&artifacts.output_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(
|
fs::write(
|
||||||
&artifacts.run_json,
|
&artifacts.run_json,
|
||||||
serde_json::to_vec_pretty(&serde_json::json!({
|
serde_json::to_vec_pretty(&serde_json::json!({
|
||||||
"binary": config.binary,
|
"binary": config.binary,
|
||||||
|
"args": &config.command_args,
|
||||||
"workspace": config.workspace,
|
"workspace": config.workspace,
|
||||||
"home": config.home,
|
"home": config.home,
|
||||||
"xdg_data_home": config.xdg_data_home,
|
"xdg_data_home": config.xdg_data_home,
|
||||||
|
|
@ -321,6 +341,7 @@ impl PanelHarness {
|
||||||
"rows": config.terminal_size.1,
|
"rows": config.terminal_size.1,
|
||||||
},
|
},
|
||||||
"hold_background_task": config.hold_background_task,
|
"hold_background_task": config.hold_background_task,
|
||||||
|
"rewind_fixture": config.rewind_fixture,
|
||||||
"tested_yoi_env_policy": &env_policy,
|
"tested_yoi_env_policy": &env_policy,
|
||||||
}))?,
|
}))?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -331,9 +352,7 @@ impl PanelHarness {
|
||||||
|
|
||||||
let mut command = Command::new(&config.binary);
|
let mut command = Command::new(&config.binary);
|
||||||
command
|
command
|
||||||
.arg("panel")
|
.args(&config.command_args)
|
||||||
.arg("--workspace")
|
|
||||||
.arg(&config.workspace)
|
|
||||||
.env_clear()
|
.env_clear()
|
||||||
.env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl)
|
.env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl)
|
||||||
.env("YOI_POD_RUNTIME_COMMAND", &config.binary)
|
.env("YOI_POD_RUNTIME_COMMAND", &config.binary)
|
||||||
|
|
@ -349,6 +368,9 @@ impl PanelHarness {
|
||||||
if let Some(task) = &config.hold_background_task {
|
if let Some(task) = &config.hold_background_task {
|
||||||
command.env("YOI_TUI_TEST_HOLD_BACKGROUND_TASK", 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 child = command.spawn()?;
|
||||||
|
|
||||||
let output = Arc::new(Mutex::new(Vec::new()));
|
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<HarnessEvent> {
|
||||||
|
self.wait_for(event_name, timeout, |event| event.event == event_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count_events(&mut self, event_name: &str) -> Result<usize> {
|
||||||
|
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<()> {
|
pub fn expect_background_task_pending(&mut self, task: &str) -> Result<()> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
loop {
|
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<()> {
|
pub fn press(&mut self, key: KeyPress) -> Result<()> {
|
||||||
match key {
|
match key {
|
||||||
KeyPress::CtrlC => self.write_input("Ctrl+C", b"\x03"),
|
KeyPress::CtrlC => self.write_input("Ctrl+C", b"\x03"),
|
||||||
KeyPress::CtrlD => self.write_input("Ctrl+D", b"\x04"),
|
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::Enter => self.write_input("Enter", b"\r"),
|
||||||
KeyPress::Esc => self.write_input("Esc", b"\x1b"),
|
KeyPress::Esc => self.write_input("Esc", b"\x1b"),
|
||||||
KeyPress::Text(text) => self.write_input(&format!("text {text:?}"), text.as_bytes()),
|
KeyPress::Text(text) => self.write_input(&format!("text {text:?}"), text.as_bytes()),
|
||||||
|
|
@ -587,6 +691,13 @@ impl PanelHarness {
|
||||||
.unwrap_or(false)
|
.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<()> {
|
fn flush_output_artifact(&self) -> Result<()> {
|
||||||
if let Ok(output) = self.output.lock() {
|
if let Ok(output) = self.output.lock() {
|
||||||
fs::write(&self.artifacts.output_log, &*output)?;
|
fs::write(&self.artifacts.output_log, &*output)?;
|
||||||
|
|
@ -744,6 +855,12 @@ impl FixtureWorkspace {
|
||||||
fixture_root: self.root.clone(),
|
fixture_root: self.root.clone(),
|
||||||
terminal_size: (100, 32),
|
terminal_size: (100, 32),
|
||||||
hold_background_task: None,
|
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(),
|
artifacts_dir: self.artifacts_dir.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -758,6 +875,19 @@ impl FixtureWorkspace {
|
||||||
config
|
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<FixtureCleanupReport> {
|
pub fn cleanup(mut self) -> Result<FixtureCleanupReport> {
|
||||||
self.cleanup_inner(true)
|
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")
|
&& 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 {
|
fn mouse_mode_enabled(output: &[u8], enable: &[u8], disable: &[u8]) -> bool {
|
||||||
let last_enable = last_subsequence_index(output, enable);
|
let last_enable = last_subsequence_index(output, enable);
|
||||||
let last_disable = last_subsequence_index(output, disable);
|
let last_disable = last_subsequence_index(output, disable);
|
||||||
|
|
@ -1253,6 +1392,7 @@ mod tests {
|
||||||
"XDG_DATA_HOME",
|
"XDG_DATA_HOME",
|
||||||
"XDG_STATE_HOME",
|
"XDG_STATE_HOME",
|
||||||
"XDG_CONFIG_HOME",
|
"XDG_CONFIG_HOME",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
"YOI_POD_RUNTIME_COMMAND",
|
"YOI_POD_RUNTIME_COMMAND",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -1266,6 +1406,7 @@ mod tests {
|
||||||
"XDG_DATA_HOME",
|
"XDG_DATA_HOME",
|
||||||
"XDG_STATE_HOME",
|
"XDG_STATE_HOME",
|
||||||
"XDG_CONFIG_HOME",
|
"XDG_CONFIG_HOME",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
"TERM",
|
"TERM",
|
||||||
"YOI_TUI_TEST_EVENTS",
|
"YOI_TUI_TEST_EVENTS",
|
||||||
"YOI_POD_RUNTIME_COMMAND",
|
"YOI_POD_RUNTIME_COMMAND",
|
||||||
|
|
|
||||||
|
|
@ -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))?;
|
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
|
||||||
|
|
||||||
panel.expect_mouse_capture_enabled()?;
|
panel.expect_mouse_capture_enabled()?;
|
||||||
|
panel.assert_no_full_drag_mouse_capture()?;
|
||||||
let rows = panel.wait_for_rows(2)?;
|
let rows = panel.wait_for_rows(2)?;
|
||||||
assert_no_runtime_or_host_pod_leak(
|
assert_no_runtime_or_host_pod_leak(
|
||||||
&fixture,
|
&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 {}",
|
"mouse selection must not dispatch panel actions; artifacts at {}",
|
||||||
panel.artifacts().dir.display()
|
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)?;
|
panel.press(KeyPress::CtrlC)?;
|
||||||
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
||||||
|
|
|
||||||
61
tests/e2e/tests/rewind.rs
Normal file
61
tests/e2e/tests/rewind.rs
Normal file
|
|
@ -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(())
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user