merge: e2e critical path
# Conflicts: # .yoi/tickets/00001KV10SN02/item.md # .yoi/tickets/00001KV10SN02/thread.md
This commit is contained in:
commit
2d9dd7d5e9
|
|
@ -2,7 +2,7 @@
|
|||
title: 'E2E: close remaining critical-path gaps after panel harness'
|
||||
state: 'inprogress'
|
||||
created_at: '2026-06-13T17:34:41Z'
|
||||
updated_at: '2026-06-14T05:13:26Z'
|
||||
updated_at: '2026-06-14T05:33:43Z'
|
||||
assignee: null
|
||||
readiness: 'implementation_ready'
|
||||
risk_flags: ['e2e', 'tui', 'pty', 'quit-latency', 'mouse-input', 'rewind']
|
||||
|
|
|
|||
|
|
@ -98,3 +98,64 @@ Replacement Coder task:
|
|||
- 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.
|
||||
|
|
|
|||
|
|
@ -1277,12 +1277,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()
|
||||
|
|
|
|||
|
|
@ -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>),
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub rewind_fixture: bool,
|
||||
pub command_args: Vec<String>,
|
||||
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<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<()> {
|
||||
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<FixtureCleanupReport> {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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())?;
|
||||
|
|
|
|||
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