merge: e2e critical path

# Conflicts:
#	.yoi/tickets/00001KV10SN02/item.md
#	.yoi/tickets/00001KV10SN02/thread.md
This commit is contained in:
Keisuke Hirata 2026-06-14 14:36:15 +09:00
commit 2d9dd7d5e9
No known key found for this signature in database
8 changed files with 568 additions and 10 deletions

View File

@ -2,7 +2,7 @@
title: 'E2E: close remaining critical-path gaps after panel harness' title: 'E2E: close remaining critical-path gaps after panel harness'
state: 'inprogress' state: 'inprogress'
created_at: '2026-06-13T17:34:41Z' created_at: '2026-06-13T17:34:41Z'
updated_at: '2026-06-14T05:13:26Z' updated_at: '2026-06-14T05:33:43Z'
assignee: null assignee: null
readiness: 'implementation_ready' readiness: 'implementation_ready'
risk_flags: ['e2e', 'tui', 'pty', 'quit-latency', 'mouse-input', 'rewind'] risk_flags: ['e2e', 'tui', 'pty', 'quit-latency', 'mouse-input', 'rewind']

View File

@ -98,3 +98,64 @@ Replacement Coder task:
- Do not merge or clean up; Orchestrator will handle review/merge. - Do not merge or clean up; Orchestrator will handle review/merge.
--- ---
<!-- event: implementation_report author: hare at: 2026-06-14T05:24:37Z -->
## Implementation report
Implemented critical-path TUI E2E coverage in commit b9f49eee.
Files changed:
- crates/tui/src/multi_pod.rs: panel mouse wheel events now move selection and emit e2e observer diagnostics without enabling full drag-motion capture.
- crates/tui/src/single_pod.rs: e2e-test-only canned single-Pod rewind fixture path for real TUI/PTTY driving; preserves production/non-production boundary.
- tests/e2e/Cargo.toml: disables auto-discovered integration tests and registers panel/rewind only behind the e2e feature.
- tests/e2e/src/lib.rs: harness support for rewind fixture env isolation, wheel input, event waiting, and forbidden ?1002h/?1003h capture detection.
- tests/e2e/tests/panel.rs: adds PTY wheel regression coverage and drag-capture failure assertion while keeping existing panel cases.
- tests/e2e/tests/rewind.rs: adds single-Pod rewind picker/apply/duplicate-Enter regression.
Validation:
- cargo fmt --check
- git diff --check
- cargo test -p yoi-e2e --no-run
- cargo build -p yoi
- cargo test -p yoi-e2e --features e2e
- cargo test -p tui --lib single_pod::tests::rewind
- cargo test -p tui --lib mouse
- nix build .#yoi
Additional note:
- Full cargo test -p tui was also attempted and failed in pre-existing/unrelated tests: multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace and spawn::tests::{profile_choices_include_builtin_and_project_default_marker, profile_choices_use_project_registry_default}. Focused changed-area tui tests passed.
Residual risks:
- Rewind E2E uses an e2e-test-only canned in-process fixture instead of a provider/socket-backed Pod, intentionally avoiding credentials/network while still exercising the real single-Pod TUI/PTTY path.
- nix build emitted the expected dirty-tree warning before succeeding.
---
<!-- event: review author: hare at: 2026-06-14T05:33:43Z status: approve -->
## Review: approve
APPROVE.
Review evidence:
- Diff base: inspected `git diff orchestration/yoi-orchestrator..HEAD`; changed files are limited to this Ticket record plus intended TUI/E2E files (`crates/tui/src/{multi_pod.rs,single_pod.rs}`, `tests/e2e/{Cargo.toml,src/lib.rs,tests/panel.rs,tests/rewind.rs}`). No unrelated Ticket records or broad code churn were present.
- Existing Panel E2E remains opt-in: `tests/e2e/Cargo.toml` uses `autotests = false`; `panel` and `rewind` integration tests have `required-features = ["e2e"]`. `cargo test -p yoi-e2e --no-run` compiled only the library test, while `cargo test -p yoi-e2e --features e2e` ran the isolated panel/rewind cases.
- Wheel PTY coverage is meaningful: the panel test drives real PTY SGR wheel input at a row coordinate derived from `rows_rendered`, then observes `mouse_wheel`, `selection_changed`, and refreshed `rows_rendered` events. This exercises terminal input delivery and row/list selection behavior rather than direct state mutation.
- Mouse capture assertions cover the intended invariant: the TUI now enables normal tracking + SGR through the local `EnableWheelMouseCapture` command, and tests assert mouse capture is enabled while rejecting forbidden full drag-motion modes. Observed PTY artifacts from the reviewer run contained `?1000h`/`?1006h` once and `?1002h`/`?1003h` zero times for panel and rewind runs.
- Rewind E2E uses a real single-Pod TUI/PTTY surface with an `e2e-test`-only fixture. The test sends `Ctrl+R`, `Enter`, and repeated `Enter`; observed events show picker open, exactly one rewind submit, duplicate Enter suppression while pending, and `rewind_applied` with restored composer text. The PTY output artifact also contained the restored text (`revise the plan`), so this covers visible composer restoration without Esc/restart/restore.
- Credential/network isolation is preserved: the E2E harness uses `env_clear`, fixture HOME/XDG runtime/data/config/state, explicit runtime binary override, and default-deny provider credential policy. The rewind fixture is only compiled/activated behind the `e2e-test` feature and `YOI_TUI_TEST_REWIND_FIXTURE`; normal `cargo build -p yoi` passed without that fixture.
Validation performed:
- `cargo fmt --check` — pass
- `git diff --check orchestration/yoi-orchestrator..HEAD` — pass
- `cargo test -p yoi-e2e --no-run` — pass
- `cargo build -p yoi` — pass
- `cargo test -p yoi-e2e --features e2e` — pass (1 lib env-policy test, 3 panel tests, 1 rewind test)
- `cargo test -p tui --lib single_pod::tests::rewind` — pass (5 tests)
- `cargo test -p tui --lib mouse` — pass (4 tests)
- `cargo test -p tui --lib` — fails in known unrelated tests already noted by implementation report: `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` and `spawn::tests::{profile_choices_include_builtin_and_project_default_marker, profile_choices_use_project_registry_default}`; 327 other tests passed, including the changed-area mouse/rewind tests.
- `nix build .#yoi` — not run by reviewer because this review grant allows writes only under `target/` and this Ticket record; a normal nix build would write outside that boundary (store/result link). Coder's implementation report recorded a successful nix build with the expected dirty-tree warning.
Residual risk:
- The rewind E2E intentionally uses an in-process canned rewind fixture instead of a provider/socket-backed Pod to avoid credentials and network. This is acceptable for the Ticket's critical TUI/PTTY regression focus, but it is not full provider integration coverage.

View File

@ -1277,12 +1277,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()

View File

@ -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>),

View File

@ -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"]

View File

@ -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",

View File

@ -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
View 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(())
}