From fc1ee5bb55b7088f792d9e4a817f4c24d9e023f0 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 23:13:06 +0900 Subject: [PATCH] tui: measure panel dashboard readiness --- crates/tui/src/multi_pod.rs | 197 ++++++++++++++++++++++++++++++++++-- tests/e2e/src/lib.rs | 162 +++++++++++++++++++++++++++++ tests/e2e/tests/panel.rs | 46 +++++++-- 3 files changed, 386 insertions(+), 19 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 4724c3a1..9d52e492 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -925,14 +925,14 @@ impl PanelRowHitBox { } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRowKey { kind: &'static str, id: String, } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRect { x: u16, y: u16, @@ -941,7 +941,7 @@ struct PanelE2eRect { } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRenderedRow { key: PanelE2eRowKey, title: String, @@ -951,12 +951,49 @@ struct PanelE2eRenderedRow { } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRowsRendered { selected: Option, rows: Vec, } +#[cfg(feature = "e2e-test")] +#[derive(Debug, Serialize)] +struct PanelE2eDashboardContentReady { + ticket_configured: bool, + selected: Option, + categories: PanelE2eDashboardCategories, + diagnostics: Vec, + rows: Vec, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Serialize)] +struct PanelE2eDashboardCategories { + ticket_rows: usize, + ready_ticket_rows: usize, + planning_ticket_rows: usize, + pod_rows: usize, + actionable_rows: usize, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Serialize)] +struct PanelE2eSourceTiming { + source: &'static str, + elapsed_ms: u128, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Serialize)] +struct PanelE2eDashboardSourceBreakdown { + total_elapsed_ms: u128, + sources: Vec, + ticket_rows: usize, + pod_rows: usize, + diagnostics: usize, +} + #[cfg(feature = "e2e-test")] fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { match key { @@ -992,6 +1029,34 @@ fn panel_e2e_rect(rect: Rect) -> PanelE2eRect { } } +#[cfg(feature = "e2e-test")] +fn panel_e2e_dashboard_categories(rows: &[PanelE2eRenderedRow]) -> PanelE2eDashboardCategories { + PanelE2eDashboardCategories { + ticket_rows: rows.iter().filter(|row| row.key.kind == "ticket").count(), + ready_ticket_rows: rows + .iter() + .filter(|row| row.key.kind == "ticket" && row.status.as_deref() == Some("ready")) + .count(), + planning_ticket_rows: rows + .iter() + .filter(|row| row.key.kind == "ticket" && row.status.as_deref() == Some("planning")) + .count(), + pod_rows: rows.iter().filter(|row| row.key.kind == "pod").count(), + actionable_rows: rows.iter().filter(|row| row.action.is_some()).count(), + } +} + +#[cfg(feature = "e2e-test")] +fn panel_e2e_dashboard_content_is_ready( + ticket_configured: bool, + categories: &PanelE2eDashboardCategories, +) -> bool { + ticket_configured + && categories.ready_ticket_rows > 0 + && categories.planning_ticket_rows > 0 + && categories.pod_rows > 0 +} + pub(crate) struct MultiPodApp { pub(crate) list: PodList, pub(crate) panel: WorkspacePanelViewModel, @@ -1010,6 +1075,8 @@ pub(crate) struct MultiPodApp { last_orchestrator_lifecycle_failure: Option, orchestrator_work_set: OrchestratorWorkSet, orchestrator_queue_attention: Option, + #[cfg(feature = "e2e-test")] + emitted_dashboard_content_ready: bool, } impl MultiPodApp { @@ -1046,6 +1113,8 @@ impl MultiPodApp { last_orchestrator_lifecycle_failure: None, orchestrator_work_set: OrchestratorWorkSet::default(), orchestrator_queue_attention: None, + #[cfg(feature = "e2e-test")] + emitted_dashboard_content_ready: false, } } @@ -1355,8 +1424,8 @@ impl MultiPodApp { } #[cfg(feature = "e2e-test")] - fn emit_rows_rendered(&self) { - let rows = self + fn emit_rows_rendered(&mut self) { + let rows: Vec<_> = self .row_hit_boxes .iter() .map(|hit| { @@ -1386,14 +1455,35 @@ impl MultiPodApp { } }) .collect(); + let selected = self.selected_row.as_ref().map(panel_e2e_row_key); crate::e2e_observer::emit( "panel", "rows_rendered", PanelE2eRowsRendered { - selected: self.selected_row.as_ref().map(panel_e2e_row_key), - rows, + selected: selected.clone(), + rows: rows.clone(), }, ); + if !self.emitted_dashboard_content_ready { + let categories = panel_e2e_dashboard_categories(&rows); + if panel_e2e_dashboard_content_is_ready( + self.panel.header.ticket_configured, + &categories, + ) { + crate::e2e_observer::emit( + "panel", + "dashboard_content_ready", + PanelE2eDashboardContentReady { + ticket_configured: self.panel.header.ticket_configured, + selected, + categories, + diagnostics: self.panel.header.diagnostics.clone(), + rows, + }, + ); + self.emitted_dashboard_content_ready = true; + } + } } fn ensure_selection_visible(&mut self) { @@ -2286,12 +2376,35 @@ async fn load_multi_pod_snapshot( lifecycle_mode: OrchestratorLifecycleMode, ) -> Result { let workspace_root = current_workspace_root(); + #[cfg(feature = "e2e-test")] + let load_started = Instant::now(); + #[cfg(feature = "e2e-test")] + let mut source_timings = Vec::new(); let companion_pod_name = workspace_companion_pod_name(&workspace_root); let list_selected_name = selected_name .clone() .or_else(|| Some(companion_pod_name.clone())); + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let mut list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "pod_list.initial", + elapsed_ms: source_started.elapsed().as_millis(), + }); + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let companion_presence = load_exact_companion_pod_presence(&companion_pod_name).await?; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "companion.presence", + elapsed_ms: source_started.elapsed().as_millis(), + }); + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let companion = match lifecycle_mode.clone() { OrchestratorLifecycleMode::Ensure { runtime_command } => { ensure_workspace_companion( @@ -2306,17 +2419,48 @@ async fn load_multi_pod_snapshot( observe_workspace_companion(companion_pod_name, companion_presence) } }; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "companion.lifecycle", + elapsed_ms: source_started.elapsed().as_millis(), + }); if companion.reload_pods { + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "pod_list.after_companion_reload", + elapsed_ms: source_started.elapsed().as_millis(), + }); } + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let config = ticket_config_availability(&workspace_root); + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "ticket_config", + elapsed_ms: source_started.elapsed().as_millis(), + }); + let orchestrator_pod_name = workspace_orchestrator_pod_name(&workspace_root); + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let orchestrator_presence = match &config { TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None, TicketConfigAvailability::Usable => { Some(load_exact_pod_presence(&orchestrator_pod_name).await?) } }; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "orchestrator.presence", + elapsed_ms: source_started.elapsed().as_millis(), + }); + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let orchestrator = match lifecycle_mode { OrchestratorLifecycleMode::Ensure { runtime_command } => { ensure_workspace_orchestrator( @@ -2332,14 +2476,51 @@ async fn load_multi_pod_snapshot( observe_workspace_orchestrator(config, orchestrator_pod_name, orchestrator_presence) } }; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "orchestrator.lifecycle", + elapsed_ms: source_started.elapsed().as_millis(), + }); if orchestrator.reload_pods { + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); list = load_pod_list(list_selected_name, MAX_ENTRIES).await?; + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "pod_list.after_orchestrator_reload", + elapsed_ms: source_started.elapsed().as_millis(), + }); } + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); let mut panel = build_workspace_panel(&workspace_root, &list); panel.header.companion = companion.state; panel.header.diagnostics.extend(companion.diagnostics); panel.header.orchestrator = orchestrator.state; panel.header.diagnostics.extend(orchestrator.diagnostics); + #[cfg(feature = "e2e-test")] + source_timings.push(PanelE2eSourceTiming { + source: "workspace_panel.build", + elapsed_ms: source_started.elapsed().as_millis(), + }); + + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "panel", + "dashboard_source_breakdown", + PanelE2eDashboardSourceBreakdown { + total_elapsed_ms: load_started.elapsed().as_millis(), + sources: source_timings, + ticket_rows: panel + .rows + .iter() + .filter(|row| row.is_ticket_action()) + .count(), + pod_rows: list.entries.len(), + diagnostics: panel.header.diagnostics.len(), + }, + ); Ok(MultiPodSnapshot { list, panel }) } diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index e551e9f1..682659c0 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -298,6 +298,104 @@ impl ExpectedPanelTicketRow { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExpectedDashboardContent { + pub tickets: Vec, + pub pod_names: Vec, +} + +impl ExpectedDashboardContent { + fn description(&self) -> String { + let tickets = self + .tickets + .iter() + .map(ExpectedPanelTicketRow::description) + .collect::>() + .join(", "); + let pods = self.pod_names.join(", "); + format!("tickets=[{tickets}] pods=[{pods}]") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DashboardContentSnapshot { + pub tickets: Vec, + pub pod_names: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardContentReady { + pub ticket_configured: bool, + pub selected: Option, + pub categories: DashboardContentCategories, + #[serde(default)] + pub diagnostics: Vec, + pub rows: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardContentCategories { + pub ticket_rows: usize, + pub ready_ticket_rows: usize, + pub planning_ticket_rows: usize, + pub pod_rows: usize, + pub actionable_rows: usize, +} + +impl DashboardContentReady { + pub fn rows_rendered(&self) -> RowsRendered { + RowsRendered { + selected: self.selected.clone(), + rows: self.rows.clone(), + } + } + + pub fn snapshot_for_expected( + &self, + expected: &ExpectedDashboardContent, + ) -> DashboardContentSnapshot { + DashboardContentSnapshot { + tickets: expected + .tickets + .iter() + .filter(|ticket| self.rows.iter().any(|row| ticket.matches(row))) + .cloned() + .collect(), + pod_names: expected + .pod_names + .iter() + .filter(|pod_name| { + self.rows + .iter() + .any(|row| row.key.kind == "pod" && row.key.id == pod_name.as_str()) + }) + .cloned() + .collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardSourceBreakdown { + pub total_elapsed_ms: u128, + pub sources: Vec, + pub ticket_rows: usize, + pub pod_rows: usize, + pub diagnostics: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardSourceTiming { + pub source: String, + pub elapsed_ms: u128, +} + +impl DashboardSourceBreakdown { + pub fn has_source(&self, source: &str) -> bool { + self.sources.iter().any(|timing| timing.source == source) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RowsRendered { pub selected: Option, @@ -542,6 +640,52 @@ impl PanelHarness { serde_json::from_value(event.data).map_err(HarnessError::from) } + /// Waits for the dashboard-content-ready observer event. Unlike first-frame + /// or row-count readiness, this requires representative user-visible content: + /// ready + planning Ticket rows and a Pod row, then checks the fixture-specific + /// rows as a small snapshot of the expected dashboard content. + pub fn wait_for_dashboard_content_ready( + &mut self, + expected: &ExpectedDashboardContent, + timeout: Duration, + ) -> Result { + let expected_snapshot = DashboardContentSnapshot { + tickets: expected.tickets.clone(), + pod_names: expected.pod_names.clone(), + }; + let description = expected.description(); + let event = self.wait_for( + format!("dashboard content ready ({description})"), + timeout, + |event| { + if event.event != "dashboard_content_ready" { + return false; + } + serde_json::from_value::(event.data.clone()) + .map(|ready| ready.snapshot_for_expected(expected) == expected_snapshot) + .unwrap_or(false) + }, + )?; + serde_json::from_value(event.data).map_err(HarnessError::from) + } + + pub fn latest_dashboard_source_breakdown( + &mut self, + ) -> Result> { + Ok(self + .events()? + .into_iter() + .rev() + .filter(|event| event.event == "dashboard_source_breakdown") + .find_map(|event| serde_json::from_value(event.data).ok())) + } + + pub fn expect_dashboard_source_breakdown(&mut self) -> Result { + self.latest_dashboard_source_breakdown()?.ok_or_else(|| { + HarnessError::Protocol("missing dashboard_source_breakdown observer event".to_string()) + }) + } + pub fn assert_fixture_ticket_row_not_rendered( &mut self, expected: &ExpectedPanelTicketRow, @@ -1041,6 +1185,24 @@ impl FixtureWorkspace { ) } + pub fn planning_fixture_ticket_row(&self) -> ExpectedPanelTicketRow { + ExpectedPanelTicketRow::new( + self.planning_ticket_id.clone(), + PLANNING_FIXTURE_TICKET_TITLE, + "planning", + ) + } + + pub fn expected_dashboard_content(&self) -> ExpectedDashboardContent { + ExpectedDashboardContent { + tickets: vec![ + self.ready_fixture_ticket_row(), + self.planning_fixture_ticket_row(), + ], + pod_names: vec!["workspace".to_string()], + } + } + pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig { PanelHarnessConfig { binary, diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 09845e11..41a04768 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500); -const ROWS_READY_BUDGET: Duration = Duration::from_secs(5); +const DASHBOARD_CONTENT_READY_BUDGET: Duration = Duration::from_secs(5); use yoi_e2e::{ ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, @@ -114,11 +114,11 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res } #[test] -fn panel_fixture_ticket_row_ready_has_startup_budget() -> yoi_e2e::Result<()> { +fn panel_dashboard_content_ready_has_startup_budget() -> yoi_e2e::Result<()> { let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; assert_fixture_paths_are_isolated(&fixture); - let ready_ticket = fixture.ready_fixture_ticket_row(); + let expected_content = fixture.expected_dashboard_content(); let started = Instant::now(); let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; @@ -137,23 +137,47 @@ fn panel_fixture_ticket_row_ready_has_startup_budget() -> yoi_e2e::Result<()> { panel.artifacts().dir.display() ); - let rows_ready_remaining = ROWS_READY_BUDGET + let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET .checked_sub(started.elapsed()) .unwrap_or_else(|| Duration::from_millis(0)); - let rows = panel.wait_for_fixture_ticket_rows_ready(&ready_ticket, rows_ready_remaining)?; + let content_ready = + panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?; assert!( - rows.has_fixture_ticket_row(&ready_ticket), - "rows-ready event must contain concrete ready fixture Ticket row; artifacts at {}", + content_ready.ticket_configured, + "dashboard content ready must include usable Ticket configuration; artifacts at {}", panel.artifacts().dir.display() ); - let rows_ready_elapsed = started.elapsed(); + assert!( + content_ready.categories.ready_ticket_rows > 0 + && content_ready.categories.planning_ticket_rows > 0 + && content_ready.categories.pod_rows > 0, + "dashboard content ready must include ready Ticket, planning Ticket, and Pod categories; got {:?}; artifacts at {}", + content_ready.categories, + panel.artifacts().dir.display() + ); + let content_ready_elapsed = started.elapsed(); eprintln!( - "panel fixture rows ready: {rows_ready_elapsed:?} (budget {ROWS_READY_BUDGET:?}); artifacts at {}", + "panel dashboard content ready: {content_ready_elapsed:?} (budget {DASHBOARD_CONTENT_READY_BUDGET:?}; first frame {first_visible_elapsed:?}); artifacts at {}", panel.artifacts().dir.display() ); assert!( - rows_ready_elapsed <= ROWS_READY_BUDGET, - "fixture rows ready took {rows_ready_elapsed:?}, budget {ROWS_READY_BUDGET:?}; artifacts at {}", + content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET, + "dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}", + panel.artifacts().dir.display() + ); + + let source_breakdown = panel.expect_dashboard_source_breakdown()?; + assert!( + source_breakdown.has_source("pod_list.initial") + && source_breakdown.has_source("ticket_config") + && source_breakdown.has_source("workspace_panel.build"), + "dashboard source breakdown should include pod, ticket, and panel-build sources; got {:?}; artifacts at {}", + source_breakdown, + panel.artifacts().dir.display() + ); + eprintln!( + "panel dashboard source breakdown: {:?}; artifacts at {}", + source_breakdown, panel.artifacts().dir.display() );