From 96561897aeee5da3298a0236824486c3c328ae17 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 23:38:51 +0900 Subject: [PATCH] test: add opt-in panel e2e harness --- .yoi/tickets/00001KSKBP9YG/item.md | 2 +- .yoi/tickets/00001KSKBP9YG/thread.md | 48 ++ Cargo.lock | 10 + Cargo.toml | 25 ++ crates/tui/src/e2e_observer.rs | 57 +++ crates/tui/src/lib.rs | 9 +- crates/tui/src/multi_pod.rs | 152 +++++++ tests/e2e/Cargo.toml | 21 + tests/e2e/src/lib.rs | 636 +++++++++++++++++++++++++++ tests/e2e/tests/panel.rs | 77 ++++ 10 files changed, 1035 insertions(+), 2 deletions(-) create mode 100644 crates/tui/src/e2e_observer.rs create mode 100644 tests/e2e/Cargo.toml create mode 100644 tests/e2e/src/lib.rs create mode 100644 tests/e2e/tests/panel.rs diff --git a/.yoi/tickets/00001KSKBP9YG/item.md b/.yoi/tickets/00001KSKBP9YG/item.md index 4699c181..f262ef79 100644 --- a/.yoi/tickets/00001KSKBP9YG/item.md +++ b/.yoi/tickets/00001KSKBP9YG/item.md @@ -2,7 +2,7 @@ title: "E2E テストハーネス" state: 'inprogress' created_at: "2026-05-27T00:00:02Z" -updated_at: '2026-06-13T14:17:40Z' +updated_at: '2026-06-13T14:38:03Z' queued_by: 'yoi ticket' queued_at: '2026-06-13T14:17:34Z' --- diff --git a/.yoi/tickets/00001KSKBP9YG/thread.md b/.yoi/tickets/00001KSKBP9YG/thread.md index f60db416..aecd50dd 100644 --- a/.yoi/tickets/00001KSKBP9YG/thread.md +++ b/.yoi/tickets/00001KSKBP9YG/thread.md @@ -170,4 +170,52 @@ Ticket を `yoi ticket` が queued にしました。 ユーザーが明示的に inprogress 化して進めることを承認した。Ticket evidence / relation records / OrchestrationPlan accepted plan / Orchestrator worktree clean state を確認済みで、blocking relation はない。Implementation side effect の前に state を inprogress として記録する。 +--- + + + +## Implementation report + +Implemented an opt-in E2E testing foundation for real `yoi panel` process automation. + +API / harness shape: +- Added workspace package `tests/e2e` (`yoi-e2e`) with required feature `e2e`, so scenarios are opt-in. +- Added `PanelHarness::spawn(...)`, `wait_for(...)`, `wait_for_rows(...)`, `click(...)`, `press(...)`, `expect_selection(...)`, `expect_exit_within(...)`, and `artifacts()`. +- Harness drives the real `yoi panel` binary through a Unix PTY and sends SGR mouse / keyboard bytes through that PTY only. +- Harness captures artifacts under a per-run artifact directory: `events.jsonl`, `input.log`, `pty-output.log`, and `run.json`. + +Production / non-production boundary: +- Harness logic stays in `tests/e2e` and is not mixed into production crates. +- Production-side change is limited to an opt-in read-only TUI JSONL observer enabled only by `YOI_TUI_TEST_EVENTS`. +- Observer records Panel/TUI synchronization and assertion events (`panel_ready`, `rows_rendered`, `selection_changed`, `mouse_click`, `action_requested`, `quit_requested`, background task lifecycle, terminal cleanup, exit). +- The observer does not mutate UI state, inject input, bypass actions, or grant authority; real input remains PTY-only. + +Scenarios added: +- Panel mouse selection regression: waits for rendered rows, sends an SGR mouse click through PTY, asserts selection changed, and asserts no panel action was dispatched. +- Panel quit latency regression: waits for Panel ready plus background-task barrier, sends Ctrl+C through PTY, asserts clean exit within the threshold, and verifies the quit event. + +Files changed: +- `Cargo.toml`, `Cargo.lock` +- `crates/tui/src/lib.rs` +- `crates/tui/src/multi_pod.rs` +- `crates/tui/src/e2e_observer.rs` +- `tests/e2e/Cargo.toml` +- `tests/e2e/src/lib.rs` +- `tests/e2e/tests/panel.rs` + +Validation: +- `cargo build -p yoi` — passed. +- `YOI_E2E_BIN=/home/hare/Projects/yoi/.worktree/e2e-harness/target/debug/yoi cargo test -p yoi-e2e --features e2e --test panel -- --nocapture` — passed (2 tests). +- `cargo fmt --check` — passed. +- `cargo check -p tui --all-targets` — passed. +- `cargo check -p yoi --all-targets` — passed. +- `cargo check -p yoi-e2e --all-targets --features e2e` — passed. +- `git diff --check` — passed. + +Remaining gaps / risks: +- The first slice is Unix PTY-based; cross-platform PTY support is not implemented. +- The screen artifact is currently raw PTY output rather than a parsed terminal snapshot. +- Fixture setup blocks Companion/Orchestrator role auto-spawn by placing intentionally invalid isolated test pod metadata in the fixture data dir; this avoids leaking live background Pods but should be replaced by a cleaner fixture strategy if the Panel gains an explicit test fixture mode. + + --- diff --git a/Cargo.lock b/Cargo.lock index ec748fa1..76baae4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4806,6 +4806,16 @@ dependencies = [ "tui", ] +[[package]] +name = "yoi-e2e" +version = "0.0.0" +dependencies = [ + "libc", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 8e7d6bae..424360ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,31 @@ members = [ "crates/ticket", "crates/project-record", "crates/workflow", + "tests/e2e", +] +default-members = [ + "crates/client", + "crates/daemon", + "crates/llm-worker", + "crates/llm-worker-macros", + "crates/session-store", + "crates/secrets", + "crates/manifest", + "crates/pod", + "crates/yoi", + "crates/pod-store", + "crates/protocol", + "crates/provider", + "crates/pod-registry", + "crates/session-metrics", + "crates/session-analytics", + "crates/lint-common", + "crates/tools", + "crates/tui", + "crates/memory", + "crates/ticket", + "crates/project-record", + "crates/workflow", ] [workspace.package] diff --git a/crates/tui/src/e2e_observer.rs b/crates/tui/src/e2e_observer.rs new file mode 100644 index 00000000..91a81a8e --- /dev/null +++ b/crates/tui/src/e2e_observer.rs @@ -0,0 +1,57 @@ +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; + +const EVENT_PATH_ENV: &str = "YOI_TUI_TEST_EVENTS"; + +static EVENT_WRITER: OnceLock>> = OnceLock::new(); + +#[derive(Serialize)] +struct EventEnvelope<'a, T> { + ts_ms: u128, + surface: &'a str, + event: &'a str, + data: T, +} + +pub(crate) fn emit(surface: &'static str, event: &'static str, data: T) +where + T: Serialize, +{ + let Some(writer) = EVENT_WRITER.get_or_init(open_event_writer).as_ref() else { + return; + }; + let Ok(mut writer) = writer.lock() else { + return; + }; + let envelope = EventEnvelope { + ts_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(), + surface, + event, + data, + }; + if serde_json::to_writer(&mut *writer, &envelope).is_ok() { + let _ = writer.write_all(b"\n"); + let _ = writer.flush(); + } +} + +fn open_event_writer() -> Option> { + let path = std::env::var_os(EVENT_PATH_ENV).map(PathBuf::from)?; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .ok() + .map(Mutex::new) +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 6f71e229..4937cb47 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -4,6 +4,7 @@ mod cache; mod command; mod composer_history; mod composer_keys; +mod e2e_observer; mod input; pub mod keys; mod markdown; @@ -108,6 +109,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { // Always restore the terminal first so any pending eprintln below // shows up cleanly in scrollback rather than inside an active // alternate-screen buffer. + e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); let mut stdout = io::stdout(); let _ = execute!( stdout, @@ -117,9 +119,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { ); let _ = disable_raw_mode(); let _ = execute!(stdout, crossterm::cursor::Show); + e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); match result { - Ok(()) => ExitCode::SUCCESS, + Ok(()) => { + e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" })); + ExitCode::SUCCESS + } Err(e) => { // SpawnError has already been painted into the inline // viewport's final frame, so it's already visible in the @@ -129,6 +135,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { if e.downcast_ref::().is_none() { eprintln!("yoi: {e}"); } + e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); ExitCode::FAILURE } } diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 8da33598..43e39d2e 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -133,6 +133,7 @@ pub(crate) async fn run( } } let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; + let mut emitted_panel_ready = false; loop { if let Some(result) = pending_queue_attention_notice.finish_if_ready().await { @@ -146,6 +147,11 @@ pub(crate) async fn run( } terminal.draw(|f| draw(f, app))?; + if !emitted_panel_ready { + crate::e2e_observer::emit("panel", "panel_ready", serde_json::json!({})); + emitted_panel_ready = true; + } + app.emit_rows_rendered(); let now = Instant::now(); if now >= next_poll { @@ -163,6 +169,7 @@ pub(crate) async fn run( TermEvent::Key(key) => match app.handle_key(key) { MultiPodAction::None => {} MultiPodAction::Quit => { + crate::e2e_observer::emit("panel", "quit_requested", serde_json::json!({})); abort_panel_background_work_for_quit( &mut pending_reload, &mut pending_queue_attention_notice, @@ -170,12 +177,22 @@ pub(crate) async fn run( return Ok(MultiPodOutcome::Quit); } MultiPodAction::Open => { + crate::e2e_observer::emit( + "panel", + "action_requested", + serde_json::json!({ "action": "open" }), + ); if let Some(request) = app.prepare_open() { terminal.draw(|f| draw(f, app))?; return Ok(MultiPodOutcome::Open(request)); } } MultiPodAction::DispatchTicketAction(request) => { + crate::e2e_observer::emit( + "panel", + "action_requested", + serde_json::json!({ "action": "ticket_action" }), + ); pending_reload.abort(); pending_queue_attention_notice.abort(); terminal.draw(|f| draw(f, app))?; @@ -187,6 +204,11 @@ pub(crate) async fn run( next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; } MultiPodAction::LaunchIntake(request) => { + crate::e2e_observer::emit( + "panel", + "action_requested", + serde_json::json!({ "action": "launch_intake" }), + ); pending_reload.abort(); pending_queue_attention_notice.abort(); terminal.draw(|f| draw(f, app))?; @@ -198,6 +220,11 @@ pub(crate) async fn run( next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; } MultiPodAction::SendCompanion(request) => { + crate::e2e_observer::emit( + "panel", + "action_requested", + serde_json::json!({ "action": "send_companion" }), + ); pending_reload.abort(); pending_queue_attention_notice.abort(); terminal.draw(|f| draw(f, app))?; @@ -228,6 +255,14 @@ impl PendingReload { if self.handle.is_some() { return false; } + crate::e2e_observer::emit( + "panel", + "background_task_started", + serde_json::json!({ + "task": "reload", + "lifecycle_mode": format!("{lifecycle_mode:?}"), + }), + ); self.handle = Some(tokio::spawn(async move { load_multi_pod_snapshot(None, lifecycle_mode).await })); @@ -252,6 +287,11 @@ impl PendingReload { return None; } let handle = self.handle.take()?; + crate::e2e_observer::emit( + "panel", + "background_task_finished", + serde_json::json!({ "task": "reload" }), + ); Some(match handle.await { Ok(result) => result, Err(e) => Err(MultiPodError::Io(io::Error::other(format!( @@ -262,6 +302,11 @@ impl PendingReload { fn abort(&mut self) { if let Some(handle) = self.handle.take() { + crate::e2e_observer::emit( + "panel", + "background_task_aborted", + serde_json::json!({ "task": "reload" }), + ); handle.abort(); } } @@ -753,6 +798,57 @@ impl PanelRowHitBox { } } +#[derive(Debug, Serialize)] +struct PanelE2eRowKey { + kind: &'static str, + id: String, +} + +#[derive(Debug, Serialize)] +struct PanelE2eRect { + x: u16, + y: u16, + width: u16, + height: u16, +} + +#[derive(Debug, Serialize)] +struct PanelE2eRenderedRow { + key: PanelE2eRowKey, + title: String, + status: Option, + action: Option<&'static str>, + rect: PanelE2eRect, +} + +#[derive(Debug, Serialize)] +struct PanelE2eRowsRendered { + selected: Option, + rows: Vec, +} + +fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { + match key { + PanelRowKey::Ticket(id) => PanelE2eRowKey { + kind: "ticket", + id: id.clone(), + }, + PanelRowKey::Pod(name) => PanelE2eRowKey { + kind: "pod", + id: name.clone(), + }, + } +} + +fn panel_e2e_rect(rect: Rect) -> PanelE2eRect { + PanelE2eRect { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + } +} + pub(crate) struct MultiPodApp { pub(crate) list: PodList, pub(crate) panel: WorkspacePanelViewModel, @@ -1069,6 +1165,15 @@ impl MultiPodApp { else { return false; }; + crate::e2e_observer::emit( + "panel", + "mouse_click", + serde_json::json!({ + "column": event.column, + "row": event.row, + "target": panel_e2e_row_key(&key), + }), + ); self.select_panel_key(key); true } @@ -1077,6 +1182,42 @@ impl MultiPodApp { self.row_hit_boxes = row_hit_boxes(rows, area); } + fn emit_rows_rendered(&self) { + let rows = self + .row_hit_boxes + .iter() + .map(|hit| { + let panel_row = self.panel.row(&hit.key); + let (title, status, action) = match panel_row { + Some(row) => ( + row.title.clone(), + Some(row.status.clone()), + row.next_action.map(NextUserAction::label), + ), + None => match &hit.key { + PanelRowKey::Pod(name) => (name.clone(), None, None), + PanelRowKey::Ticket(id) => (id.clone(), None, None), + }, + }; + PanelE2eRenderedRow { + key: panel_e2e_row_key(&hit.key), + title, + status, + action, + rect: panel_e2e_rect(hit.rect), + } + }) + .collect(); + crate::e2e_observer::emit( + "panel", + "rows_rendered", + PanelE2eRowsRendered { + selected: self.selected_row.as_ref().map(panel_e2e_row_key), + rows, + }, + ); + } + fn ensure_selection_visible(&mut self) { let visible = visible_panel_keys(&self.panel, &self.list); if visible.is_empty() { @@ -1127,12 +1268,23 @@ impl MultiPodApp { if let PanelRowKey::Pod(name) = &key { self.list.selected_name = Some(name.clone()); } + let selected_key = key.clone(); self.selected_row = Some(key); + crate::e2e_observer::emit( + "panel", + "selection_changed", + serde_json::json!({ "selected": panel_e2e_row_key(&selected_key) }), + ); } fn clear_panel_selection(&mut self) { self.selected_row = None; self.list.selected_name = None; + crate::e2e_observer::emit( + "panel", + "selection_changed", + serde_json::json!({ "selected": serde_json::Value::Null }), + ); } fn ensure_composer_target_available(&mut self) { diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml new file mode 100644 index 00000000..dc5b0690 --- /dev/null +++ b/tests/e2e/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "yoi-e2e" +version = "0.0.0" +edition.workspace = true +license.workspace = true +publish = false + +[features] +default = [] +e2e = [] + +[dependencies] +libc.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +tempfile.workspace = true + +[[test]] +name = "panel" +path = "tests/panel.rs" +required-features = ["e2e"] diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs new file mode 100644 index 00000000..2fb65e82 --- /dev/null +++ b/tests/e2e/src/lib.rs @@ -0,0 +1,636 @@ +//! Opt-in E2E helpers for driving the real `yoi panel` process through a PTY. +//! +//! The harness intentionally sends keyboard and mouse input only through the PTY. +//! Structured JSONL events emitted by the TUI are used for synchronization, +//! assertions, and failure artifacts; they are not an input or authority channel. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tempfile::TempDir; + +const DEFAULT_WAIT: Duration = Duration::from_secs(5); +const DEFAULT_EXIT_WAIT: Duration = Duration::from_millis(1500); + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum HarnessError { + Io(io::Error), + Json(serde_json::Error), + CommandFailed { + program: PathBuf, + status: ExitStatus, + stdout: String, + stderr: String, + }, + Timeout { + what: String, + artifacts: PanelArtifacts, + }, + MissingBinary(PathBuf), + Protocol(String), +} + +impl std::fmt::Display for HarnessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "io error: {err}"), + Self::Json(err) => write!(f, "json error: {err}"), + Self::CommandFailed { + program, + status, + stdout, + stderr, + } => write!( + f, + "{} exited with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}", + program.display() + ), + Self::Timeout { what, artifacts } => write!( + f, + "timed out waiting for {what}; artifacts at {}", + artifacts.dir.display() + ), + Self::MissingBinary(path) => write!( + f, + "missing yoi binary {}; run `cargo build -p yoi` or set YOI_E2E_BIN", + path.display() + ), + Self::Protocol(message) => write!(f, "protocol error: {message}"), + } + } +} + +impl std::error::Error for HarnessError {} + +impl From for HarnessError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl From for HarnessError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +#[derive(Debug, Clone)] +pub struct PanelHarnessConfig { + pub binary: PathBuf, + pub workspace: PathBuf, + pub home: PathBuf, + pub xdg_data_home: PathBuf, + pub xdg_state_home: PathBuf, + pub xdg_config_home: PathBuf, + pub terminal_size: (u16, u16), + pub artifacts_dir: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HarnessEvent { + pub ts_ms: u128, + pub surface: String, + pub event: String, + #[serde(default)] + pub data: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PanelRowKey { + pub kind: String, + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PanelRect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenderedPanelRow { + pub key: PanelRowKey, + pub title: String, + pub status: Option, + pub action: Option, + pub rect: PanelRect, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RowsRendered { + pub selected: Option, + pub rows: Vec, +} + +#[derive(Debug, Clone)] +pub enum KeyPress { + CtrlC, + CtrlD, + Enter, + Esc, + Text(String), +} + +#[derive(Debug, Clone)] +pub struct PanelArtifacts { + pub dir: PathBuf, + pub events_jsonl: PathBuf, + pub input_log: PathBuf, + pub output_log: PathBuf, + pub run_json: PathBuf, +} + +pub struct PanelHarness { + child: Child, + master: File, + reader: Option>, + output: Arc>>, + last_event_offset: usize, + artifacts: PanelArtifacts, +} + +impl PanelHarness { + pub fn spawn(config: PanelHarnessConfig) -> Result { + if !config.binary.exists() { + return Err(HarnessError::MissingBinary(config.binary)); + } + fs::create_dir_all(&config.artifacts_dir)?; + let artifacts = PanelArtifacts { + dir: config.artifacts_dir.clone(), + events_jsonl: config.artifacts_dir.join("events.jsonl"), + input_log: config.artifacts_dir.join("input.log"), + output_log: config.artifacts_dir.join("pty-output.log"), + run_json: config.artifacts_dir.join("run.json"), + }; + fs::write(&artifacts.events_jsonl, "")?; + fs::write(&artifacts.input_log, "")?; + fs::write(&artifacts.output_log, "")?; + fs::write( + &artifacts.run_json, + serde_json::to_vec_pretty(&serde_json::json!({ + "binary": config.binary, + "workspace": config.workspace, + "home": config.home, + "xdg_data_home": config.xdg_data_home, + "xdg_state_home": config.xdg_state_home, + "xdg_config_home": config.xdg_config_home, + "terminal_size": { + "columns": config.terminal_size.0, + "rows": config.terminal_size.1, + }, + }))?, + )?; + + let (master, slave) = open_pty(config.terminal_size)?; + let slave_for_stdin = slave.try_clone()?; + let slave_for_stdout = slave.try_clone()?; + + let mut command = Command::new(&config.binary); + command + .arg("panel") + .arg("--workspace") + .arg(&config.workspace) + .env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl) + .env("YOI_POD_RUNTIME_COMMAND", &config.binary) + .env("HOME", &config.home) + .env("XDG_DATA_HOME", &config.xdg_data_home) + .env("XDG_STATE_HOME", &config.xdg_state_home) + .env("XDG_CONFIG_HOME", &config.xdg_config_home) + .env("TERM", "xterm-256color") + .stdin(Stdio::from(slave_for_stdin)) + .stdout(Stdio::from(slave_for_stdout)) + .stderr(Stdio::from(slave)); + let child = command.spawn()?; + + let output = Arc::new(Mutex::new(Vec::new())); + let output_for_thread = Arc::clone(&output); + let mut reader_file = master.try_clone()?; + let output_log = artifacts.output_log.clone(); + let reader = thread::spawn(move || { + let mut sink = OpenOptions::new() + .append(true) + .create(true) + .open(output_log) + .ok(); + let mut buf = [0_u8; 4096]; + loop { + match reader_file.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if let Some(sink) = sink.as_mut() { + let _ = sink.write_all(&buf[..n]); + } + if let Ok(mut output) = output_for_thread.lock() { + output.extend_from_slice(&buf[..n]); + } + } + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + }); + + Ok(Self { + child, + master, + reader: Some(reader), + output, + last_event_offset: 0, + artifacts, + }) + } + + pub fn wait_for( + &mut self, + what: impl Into, + timeout: Duration, + mut predicate: F, + ) -> Result + where + F: FnMut(&HarnessEvent) -> bool, + { + let what = what.into(); + let start = Instant::now(); + loop { + for event in self.read_new_events()? { + if predicate(&event) { + return Ok(event); + } + } + if let Some(status) = self.child.try_wait()? { + self.flush_output_artifact()?; + return Err(HarnessError::Protocol(format!( + "process exited with {status} before {what}" + ))); + } + if start.elapsed() >= timeout { + self.flush_output_artifact()?; + return Err(HarnessError::Timeout { + what, + artifacts: self.artifacts.clone(), + }); + } + thread::sleep(Duration::from_millis(20)); + } + } + + pub fn wait_for_rows(&mut self, min_rows: usize) -> Result { + let event = self.wait_for("rows_rendered", DEFAULT_WAIT, |event| { + event.event == "rows_rendered" + && event + .data + .get("rows") + .and_then(Value::as_array) + .is_some_and(|rows| rows.len() >= min_rows) + })?; + serde_json::from_value(event.data).map_err(HarnessError::from) + } + + pub fn click(&mut self, row: &RenderedPanelRow) -> Result<()> { + let x = row.rect.x.saturating_add(1); + let y = row.rect.y; + self.write_input( + &format!("mouse click {} at {},{}", row.title, x, y), + format!("\u{1b}[<0;{};{}M", 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::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()), + } + } + + pub fn expect_selection(&mut self, expected: &PanelRowKey) -> Result { + self.wait_for("selection_changed", DEFAULT_WAIT, |event| { + event.event == "selection_changed" + && event.data.get("selected").is_some_and(|selected| { + serde_json::from_value::(selected.clone()) + .is_ok_and(|actual| actual == *expected) + }) + }) + } + + pub fn expect_exit_within(&mut self, timeout: Duration) -> Result { + let start = Instant::now(); + loop { + if let Some(status) = self.child.try_wait()? { + self.flush_output_artifact()?; + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + return Ok(status); + } + if start.elapsed() >= timeout { + self.flush_output_artifact()?; + return Err(HarnessError::Timeout { + what: format!("process exit within {timeout:?}"), + artifacts: self.artifacts.clone(), + }); + } + thread::sleep(Duration::from_millis(10)); + } + } + + pub fn events(&mut self) -> Result> { + let text = fs::read_to_string(&self.artifacts.events_jsonl)?; + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).map_err(HarnessError::from)) + .collect() + } + + pub fn artifacts(&self) -> &PanelArtifacts { + &self.artifacts + } + + pub fn default_exit_wait() -> Duration { + DEFAULT_EXIT_WAIT + } + + fn read_new_events(&mut self) -> Result> { + let text = fs::read_to_string(&self.artifacts.events_jsonl)?; + let mut events = Vec::new(); + let new_text = text.get(self.last_event_offset..).unwrap_or_default(); + let mut consumed = self.last_event_offset; + for segment in new_text.split_inclusive('\n') { + if !segment.ends_with('\n') { + break; + } + consumed += segment.len(); + let line = segment.trim(); + if !line.is_empty() { + events.push(serde_json::from_str(line)?); + } + } + self.last_event_offset = consumed; + Ok(events) + } + + fn write_input(&mut self, label: &str, bytes: &[u8]) -> Result<()> { + let mut log = OpenOptions::new() + .append(true) + .create(true) + .open(&self.artifacts.input_log)?; + writeln!(log, "{} {} bytes {label}", now_ms(), bytes.len())?; + self.master.write_all(bytes)?; + self.master.flush()?; + Ok(()) + } + + fn flush_output_artifact(&self) -> Result<()> { + if let Ok(output) = self.output.lock() { + fs::write(&self.artifacts.output_log, &*output)?; + } + Ok(()) + } +} + +impl Drop for PanelHarness { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + let _ = self.flush_output_artifact(); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +#[derive(Debug)] +pub struct FixtureWorkspace { + _temp: TempDir, + pub workspace: PathBuf, + pub home: PathBuf, + pub xdg_data_home: PathBuf, + pub xdg_state_home: PathBuf, + pub xdg_config_home: PathBuf, + pub artifacts_dir: PathBuf, +} + +impl FixtureWorkspace { + pub fn new(binary: &Path) -> Result { + let temp = tempfile::Builder::new().prefix("yoi-e2e-").tempdir()?; + let root = temp.path(); + let workspace = root.join("workspace"); + let home = root.join("home"); + let xdg_data_home = root.join("data"); + let xdg_state_home = root.join("state"); + let xdg_config_home = root.join("config"); + let artifacts_dir = root.join("artifacts"); + for dir in [ + &workspace, + &home, + &xdg_data_home, + &xdg_state_home, + &xdg_config_home, + &artifacts_dir, + ] { + fs::create_dir_all(dir)?; + } + write_blocking_pod_metadata(&xdg_data_home, "workspace")?; + write_blocking_pod_metadata(&xdg_data_home, "workspace-orchestrator")?; + run_yoi( + binary, + &workspace, + &home, + &xdg_data_home, + &xdg_state_home, + &xdg_config_home, + &["ticket", "init"], + )?; + let first = create_ticket( + binary, + &workspace, + &home, + &xdg_data_home, + &xdg_state_home, + &xdg_config_home, + "Ready E2E Ticket", + )?; + run_yoi( + binary, + &workspace, + &home, + &xdg_data_home, + &xdg_state_home, + &xdg_config_home, + &["ticket", "state", &first, "ready"], + )?; + let _second = create_ticket( + binary, + &workspace, + &home, + &xdg_data_home, + &xdg_state_home, + &xdg_config_home, + "Planning E2E Ticket", + )?; + Ok(Self { + _temp: temp, + workspace, + home, + xdg_data_home, + xdg_state_home, + xdg_config_home, + artifacts_dir, + }) + } + + pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig { + PanelHarnessConfig { + binary, + workspace: self.workspace.clone(), + home: self.home.clone(), + xdg_data_home: self.xdg_data_home.clone(), + xdg_state_home: self.xdg_state_home.clone(), + xdg_config_home: self.xdg_config_home.clone(), + terminal_size: (100, 32), + artifacts_dir: self.artifacts_dir.clone(), + } + } +} + +pub fn yoi_binary() -> PathBuf { + if let Some(path) = std::env::var_os("YOI_E2E_BIN") { + return PathBuf::from(path); + } + let mut path = std::env::current_exe().expect("current executable path"); + while let Some(name) = path.file_name().and_then(|name| name.to_str()) { + if name == "debug" || name == "release" { + path.push("yoi"); + return path; + } + path.pop(); + } + PathBuf::from("target/debug/yoi") +} + +fn open_pty(size: (u16, u16)) -> Result<(File, File)> { + let mut master = 0; + let mut slave = 0; + let mut winsize = libc::winsize { + ws_row: size.1, + ws_col: size.0, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let rc = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null(), + &mut winsize, + ) + }; + if rc != 0 { + return Err(io::Error::last_os_error().into()); + } + let master = unsafe { File::from_raw_fd(master) }; + let slave = unsafe { File::from_raw_fd(slave) }; + let _ = unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFL, 0) }; + Ok((master, slave)) +} + +fn create_ticket( + binary: &Path, + workspace: &Path, + home: &Path, + data: &Path, + state: &Path, + config: &Path, + title: &str, +) -> Result { + let output = run_yoi_capture( + binary, + workspace, + home, + data, + state, + config, + &["ticket", "create", "--title", title], + )?; + output + .split_whitespace() + .find(|part| part.len() >= 13 && part.chars().all(|ch| ch.is_ascii_alphanumeric())) + .map(ToOwned::to_owned) + .ok_or_else(|| HarnessError::Protocol(format!("could not parse ticket id from {output:?}"))) +} + +fn run_yoi( + binary: &Path, + workspace: &Path, + home: &Path, + data: &Path, + state: &Path, + config: &Path, + args: &[&str], +) -> Result<()> { + let output = run_yoi_capture(binary, workspace, home, data, state, config, args)?; + drop(output); + Ok(()) +} + +fn run_yoi_capture( + binary: &Path, + workspace: &Path, + home: &Path, + data: &Path, + state: &Path, + config: &Path, + args: &[&str], +) -> Result { + let output = Command::new(binary) + .args(args) + .current_dir(workspace) + .env("HOME", home) + .env("XDG_DATA_HOME", data) + .env("XDG_STATE_HOME", state) + .env("XDG_CONFIG_HOME", config) + .env("YOI_POD_RUNTIME_COMMAND", binary) + .output()?; + if !output.status.success() { + return Err(HarnessError::CommandFailed { + program: binary.to_path_buf(), + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + Ok(text) +} + +fn write_blocking_pod_metadata(data_home: &Path, pod_name: &str) -> Result<()> { + let dir = data_home.join("yoi").join("pods").join(pod_name); + fs::create_dir_all(&dir)?; + fs::write(dir.join("metadata.json"), b"not valid metadata for e2e\n")?; + Ok(()) +} + +fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs new file mode 100644 index 00000000..0bce20b6 --- /dev/null +++ b/tests/e2e/tests/panel.rs @@ -0,0 +1,77 @@ +use std::time::Duration; + +use yoi_e2e::{FixtureWorkspace, KeyPress, PanelHarness, yoi_binary}; + +#[test] +fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> { + let binary = yoi_binary(); + let fixture = FixtureWorkspace::new(&binary)?; + let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; + + let rows = panel.wait_for_rows(2)?; + let selected = rows.selected.clone(); + let target = rows + .rows + .iter() + .find(|row| Some(&row.key) != selected.as_ref()) + .cloned() + .expect("fixture should render a second selectable row"); + + let before_events = panel.events()?.len(); + panel.click(&target)?; + panel.expect_selection(&target.key)?; + + let events = panel.events()?; + assert!( + events[before_events..] + .iter() + .all(|event| event.event != "action_requested"), + "mouse 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())?; + assert!(status.success(), "panel should exit cleanly with Ctrl+C"); + Ok(()) +} + +#[test] +fn panel_ctrl_c_exits_promptly_after_background_barrier() -> yoi_e2e::Result<()> { + let binary = yoi_binary(); + let fixture = FixtureWorkspace::new(&binary)?; + let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; + + panel.wait_for("panel_ready", Duration::from_secs(5), |event| { + event.event == "panel_ready" + })?; + assert!( + panel + .events()? + .iter() + .any(|event| event.event == "background_task_started"), + "background task barrier was not observed; artifacts at {}", + panel.artifacts().dir.display() + ); + + let started = std::time::Instant::now(); + panel.press(KeyPress::CtrlC)?; + let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; + let elapsed = started.elapsed(); + + assert!(status.success(), "panel should exit cleanly with Ctrl+C"); + assert!( + elapsed <= PanelHarness::default_exit_wait(), + "quit latency {elapsed:?} exceeded threshold; artifacts at {}", + panel.artifacts().dir.display() + ); + assert!( + panel + .events()? + .iter() + .any(|event| event.event == "quit_requested"), + "quit_requested observability event missing; artifacts at {}", + panel.artifacts().dir.display() + ); + Ok(()) +}