diff --git a/.yoi/tickets/00001KSKBP9YG/item.md b/.yoi/tickets/00001KSKBP9YG/item.md index 5d77db50..1970b6ff 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-13T15:05:52Z' +updated_at: '2026-06-13T15:18:21Z' 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 90a0fd97..8c7bca77 100644 --- a/.yoi/tickets/00001KSKBP9YG/thread.md +++ b/.yoi/tickets/00001KSKBP9YG/thread.md @@ -298,4 +298,40 @@ Validation run in `/home/hare/Projects/yoi/.worktree/e2e-harness`: No source changes were made during review. +--- + + + +## Implementation report + +Follow-up implementation update for reviewer request `559adb9a ticket: request e2e harness changes`: + +Production/non-production boundary fix: +- Removed the normal-build no-op `e2e_observer` module path and now compile the observer module only behind `tui/e2e-test` via `#[cfg(feature = "e2e-test")] mod e2e_observer;`. +- Added `#[cfg(feature = "e2e-test")]` to all Panel/TUI observability call sites that build JSON payloads, including terminal cleanup/exit, panel ready, rows-rendered, mouse click, selection changes, action requests, quit request, and background task lifecycle events. +- `App::emit_rows_rendered`, `PanelE2eRowKey`, `PanelE2eRect`, `PanelE2eRenderedRow`, `PanelE2eRowsRendered`, and the conversion helpers are now compiled only with `tui/e2e-test`. +- Normal builds no longer call `app.emit_rows_rendered()`, no longer evaluate `serde_json::json!` e2e payloads, and no longer retain the Panel E2E DTO/helper logic in the runtime path. +- The background reload hold seam remains compiled/called only under `tui/e2e-test`; `YOI_TUI_TEST_EVENTS` and `YOI_TUI_TEST_HOLD_BACKGROUND_TASK` behavior remains feature-gated. + +Preserved E2E behavior: +- Mouse E2E still verifies PTY output for normal mouse tracking + SGR mouse enable sequences before any raw SGR click can be sent. +- `PanelHarness::click(...)` still fails if mouse capture was not observed. +- Quit latency E2E still uses the feature-gated pending reload hold barrier and asserts the reload task is pending before Ctrl+C. + +Validation: +- `cargo fmt --check` — passed. +- `cargo check -p tui --all-targets` — passed. +- `cargo check -p yoi --all-targets` — passed. +- `cargo check -p tui --all-targets --features e2e-test` — passed. +- `cargo check -p yoi --all-targets --features e2e-test` — passed. +- `cargo build -p yoi --features e2e-test` — 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 check -p yoi-e2e --all-targets --features e2e` — passed. +- `git diff --check` — passed. + +Remaining gaps / risks unchanged: +- The E2E harness remains Unix PTY based. +- The screen artifact remains raw PTY output rather than a parsed terminal snapshot. + + --- diff --git a/crates/tui/src/e2e_observer.rs b/crates/tui/src/e2e_observer.rs index 8c2749cf..77eceb60 100644 --- a/crates/tui/src/e2e_observer.rs +++ b/crates/tui/src/e2e_observer.rs @@ -1,89 +1,77 @@ -#[cfg(feature = "e2e-test")] -mod imp { - use std::fs::{File, OpenOptions}; - use std::io::Write; - use std::path::PathBuf; - use std::sync::{Mutex, OnceLock}; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; - use serde::Serialize; +use serde::Serialize; - const EVENT_PATH_ENV: &str = "YOI_TUI_TEST_EVENTS"; - const HOLD_BACKGROUND_TASK_ENV: &str = "YOI_TUI_TEST_HOLD_BACKGROUND_TASK"; +const EVENT_PATH_ENV: &str = "YOI_TUI_TEST_EVENTS"; +const HOLD_BACKGROUND_TASK_ENV: &str = "YOI_TUI_TEST_HOLD_BACKGROUND_TASK"; - static EVENT_WRITER: OnceLock>> = OnceLock::new(); +static EVENT_WRITER: OnceLock>> = OnceLock::new(); - #[derive(Serialize)] - struct EventEnvelope<'a, T> { - ts_ms: u128, - surface: &'a str, - event: &'a str, - data: T, - } +#[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(); - } - } - - pub(crate) async fn hold_background_task_if_requested(task: &'static str) { - let requested = std::env::var(HOLD_BACKGROUND_TASK_ENV).unwrap_or_default(); - if !requested - .split(',') - .map(str::trim) - .any(|requested| requested == task) - { - return; - } - emit( - "panel", - "background_task_hold_started", - serde_json::json!({ "task": task }), - ); - loop { - tokio::time::sleep(Duration::from_millis(25)).await; - } - } - - 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) +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(); } } -#[cfg(feature = "e2e-test")] -pub(crate) use imp::{emit, hold_background_task_if_requested}; +pub(crate) async fn hold_background_task_if_requested(task: &'static str) { + let requested = std::env::var(HOLD_BACKGROUND_TASK_ENV).unwrap_or_default(); + if !requested + .split(',') + .map(str::trim) + .any(|requested| requested == task) + { + return; + } + emit( + "panel", + "background_task_hold_started", + serde_json::json!({ "task": task }), + ); + loop { + tokio::time::sleep(Duration::from_millis(25)).await; + } +} -#[cfg(not(feature = "e2e-test"))] -pub(crate) fn emit(_surface: &'static str, _event: &'static str, _data: T) {} - -#[cfg(not(feature = "e2e-test"))] -pub(crate) async fn hold_background_task_if_requested(_task: &'static str) {} +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 4937cb47..579d5f25 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; +#[cfg(feature = "e2e-test")] mod e2e_observer; mod input; pub mod keys; @@ -109,6 +110,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. + #[cfg(feature = "e2e-test")] e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); let mut stdout = io::stdout(); let _ = execute!( @@ -119,10 +121,12 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { ); let _ = disable_raw_mode(); let _ = execute!(stdout, crossterm::cursor::Show); + #[cfg(feature = "e2e-test")] e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); match result { Ok(()) => { + #[cfg(feature = "e2e-test")] e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" })); ExitCode::SUCCESS } @@ -135,6 +139,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { if e.downcast_ref::().is_none() { eprintln!("yoi: {e}"); } + #[cfg(feature = "e2e-test")] 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 8175fb5e..ba7a2315 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; + #[cfg(feature = "e2e-test")] let mut emitted_panel_ready = false; loop { @@ -147,11 +148,14 @@ 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; + #[cfg(feature = "e2e-test")] + { + if !emitted_panel_ready { + crate::e2e_observer::emit("panel", "panel_ready", serde_json::json!({})); + emitted_panel_ready = true; + } + app.emit_rows_rendered(); } - app.emit_rows_rendered(); let now = Instant::now(); if now >= next_poll { @@ -169,6 +173,7 @@ pub(crate) async fn run( TermEvent::Key(key) => match app.handle_key(key) { MultiPodAction::None => {} MultiPodAction::Quit => { + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit("panel", "quit_requested", serde_json::json!({})); abort_panel_background_work_for_quit( &mut pending_reload, @@ -177,6 +182,7 @@ pub(crate) async fn run( return Ok(MultiPodOutcome::Quit); } MultiPodAction::Open => { + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "action_requested", @@ -188,6 +194,7 @@ pub(crate) async fn run( } } MultiPodAction::DispatchTicketAction(request) => { + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "action_requested", @@ -204,6 +211,7 @@ pub(crate) async fn run( next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; } MultiPodAction::LaunchIntake(request) => { + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "action_requested", @@ -220,6 +228,7 @@ pub(crate) async fn run( next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; } MultiPodAction::SendCompanion(request) => { + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "action_requested", @@ -255,6 +264,7 @@ impl PendingReload { if self.handle.is_some() { return false; } + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "background_task_started", @@ -264,6 +274,7 @@ impl PendingReload { }), ); self.handle = Some(tokio::spawn(async move { + #[cfg(feature = "e2e-test")] crate::e2e_observer::hold_background_task_if_requested("reload").await; load_multi_pod_snapshot(None, lifecycle_mode).await })); @@ -288,6 +299,7 @@ impl PendingReload { return None; } let handle = self.handle.take()?; + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "background_task_finished", @@ -303,6 +315,7 @@ impl PendingReload { fn abort(&mut self) { if let Some(handle) = self.handle.take() { + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "background_task_aborted", @@ -799,12 +812,14 @@ impl PanelRowHitBox { } } +#[cfg(feature = "e2e-test")] #[derive(Debug, Serialize)] struct PanelE2eRowKey { kind: &'static str, id: String, } +#[cfg(feature = "e2e-test")] #[derive(Debug, Serialize)] struct PanelE2eRect { x: u16, @@ -813,6 +828,7 @@ struct PanelE2eRect { height: u16, } +#[cfg(feature = "e2e-test")] #[derive(Debug, Serialize)] struct PanelE2eRenderedRow { key: PanelE2eRowKey, @@ -822,12 +838,14 @@ struct PanelE2eRenderedRow { rect: PanelE2eRect, } +#[cfg(feature = "e2e-test")] #[derive(Debug, Serialize)] struct PanelE2eRowsRendered { selected: Option, rows: Vec, } +#[cfg(feature = "e2e-test")] fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { match key { PanelRowKey::Ticket(id) => PanelE2eRowKey { @@ -841,6 +859,7 @@ fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { } } +#[cfg(feature = "e2e-test")] fn panel_e2e_rect(rect: Rect) -> PanelE2eRect { PanelE2eRect { x: rect.x, @@ -1166,6 +1185,7 @@ impl MultiPodApp { else { return false; }; + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "mouse_click", @@ -1183,6 +1203,7 @@ impl MultiPodApp { self.row_hit_boxes = row_hit_boxes(rows, area); } + #[cfg(feature = "e2e-test")] fn emit_rows_rendered(&self) { let rows = self .row_hit_boxes @@ -1269,8 +1290,10 @@ impl MultiPodApp { if let PanelRowKey::Pod(name) = &key { self.list.selected_name = Some(name.clone()); } + #[cfg(feature = "e2e-test")] let selected_key = key.clone(); self.selected_row = Some(key); + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "selection_changed", @@ -1281,6 +1304,7 @@ impl MultiPodApp { fn clear_panel_selection(&mut self) { self.selected_row = None; self.list.selected_name = None; + #[cfg(feature = "e2e-test")] crate::e2e_observer::emit( "panel", "selection_changed",