test: cover critical tui e2e paths

This commit is contained in:
2026-06-14 14:24:12 +09:00
parent 6cae63fc53
commit b9f49eee1f
6 changed files with 506 additions and 9 deletions
+32 -3
View File
@@ -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()
+196
View File
@@ -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<PathBuf>,
runtime_command: PodRuntimeCommand,
) -> 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 {
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<String>,
runtime_command: PodRuntimeCommand,
) -> 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? {
SpawnOutcome::Ready(r) => r,
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> {
Terminal(TerminalEventResult),
Pod(Option<P>),