diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 4724c3a1..667291a2 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -44,15 +44,19 @@ use crate::pod_list::{ use crate::role_session_registry::{ PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin, TicketClaimResult, }; +#[cfg(not(feature = "e2e-test"))] +use crate::workspace_panel::build_workspace_panel; +#[cfg(feature = "e2e-test")] +use crate::workspace_panel::build_workspace_panel_with_e2e_timings; use crate::workspace_panel::{ ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus, CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan, OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row, - build_workspace_panel, companion_pod_presence, decide_companion_lifecycle, - decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence, - ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name, + companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle, + local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability, + workspace_companion_pod_name, workspace_orchestrator_pod_name, }; const MAX_ENTRIES: usize = 50; @@ -925,14 +929,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,22 +945,92 @@ struct PanelE2eRect { } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRenderedRow { key: PanelE2eRowKey, title: String, status: Option, action: Option<&'static str>, + disabled_reason: Option, + local_state: Option, + overlay_state: Option, + overlay_detail: Option, rect: PanelE2eRect, } #[cfg(feature = "e2e-test")] -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] struct PanelE2eRowsRendered { selected: Option, + header: PanelE2eDashboardHeader, rows: Vec, } +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, Serialize)] +struct PanelE2eDashboardHeader { + ticket_configured: bool, + companion: Option, + orchestrator: Option, + diagnostics: Vec, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, Serialize)] +struct PanelE2eCompanionState { + pod_name: String, + status: &'static str, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, Serialize)] +struct PanelE2eOrchestratorState { + pod_name: String, + status: &'static str, + detail: Option, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Serialize)] +struct PanelE2eDashboardContentReady { + snapshot: PanelE2eDashboardSnapshot, + categories: PanelE2eDashboardCategories, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, Serialize)] +struct PanelE2eDashboardSnapshot { + header: PanelE2eDashboardHeader, + 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 +1066,76 @@ 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.local_state.as_deref() == Some("ready")) + .count(), + planning_ticket_rows: rows + .iter() + .filter(|row| { + row.key.kind == "ticket" && row.local_state.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_header(panel: &WorkspacePanelViewModel) -> PanelE2eDashboardHeader { + PanelE2eDashboardHeader { + ticket_configured: panel.header.ticket_configured, + companion: panel + .header + .companion + .as_ref() + .map(|state| PanelE2eCompanionState { + pod_name: state.pod_name.clone(), + status: state.status.label(), + }), + orchestrator: panel + .header + .orchestrator + .as_ref() + .map(|state| PanelE2eOrchestratorState { + pod_name: state.pod_name.clone(), + status: state.status.label(), + detail: state.detail.clone(), + }), + diagnostics: panel.header.diagnostics.clone(), + } +} + +#[cfg(feature = "e2e-test")] +fn panel_e2e_dashboard_content_is_ready( + snapshot: &PanelE2eDashboardSnapshot, + categories: &PanelE2eDashboardCategories, +) -> bool { + snapshot.header.ticket_configured + && snapshot.header.companion.is_some() + && snapshot.header.orchestrator.is_some() + && categories.ready_ticket_rows > 0 + && categories.planning_ticket_rows > 0 + && categories.pod_rows > 0 + && snapshot.rows.iter().any(|row| { + row.key.kind == "ticket" + && row.local_state.as_deref() == Some("ready") + && row.overlay_state.is_some() + && row.action.is_some() + && row.disabled_reason.is_some() + }) + && snapshot.rows.iter().any(|row| { + row.key.kind == "ticket" + && row.local_state.as_deref() == Some("planning") + && row.action.is_some() + && row.disabled_reason.is_some() + }) +} + pub(crate) struct MultiPodApp { pub(crate) list: PodList, pub(crate) panel: WorkspacePanelViewModel, @@ -1010,6 +1154,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 +1192,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,25 +1503,52 @@ 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| { 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), - ), + let ( + title, + status, + action, + disabled_reason, + local_state, + overlay_state, + overlay_detail, + ) = match panel_row { + Some(row) => { + let ticket = row.ticket.as_ref(); + ( + row.title.clone(), + Some(row.status.clone()), + row.next_action.map(NextUserAction::label), + row.disabled_reason.clone(), + ticket.map(|ticket| ticket.workflow_state.as_str().to_string()), + ticket + .and_then(|ticket| ticket.orchestration_overlay.as_ref()) + .map(|overlay| overlay.workflow_state.as_str().to_string()), + ticket + .and_then(|ticket| ticket.orchestration_overlay.as_ref()) + .map(|overlay| { + format!( + "{}:{}", + overlay.source, + overlay.workflow_state.as_str() + ) + }), + ) + } None => match &hit.key { - PanelRowKey::Pod(name) => (name.clone(), None, None), + PanelRowKey::Pod(name) => { + (name.clone(), None, None, None, None, None, None) + } PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => { - (id.clone(), None, None) + (id.clone(), None, None, None, None, None, None) } PanelRowKey::TicketIntakePod { pod_name, .. } => { - (pod_name.clone(), None, None) + (pod_name.clone(), None, None, None, None, None, None) } }, }; @@ -1382,18 +1557,40 @@ impl MultiPodApp { title, status, action, + disabled_reason, + local_state, + overlay_state, + overlay_detail, rect: panel_e2e_rect(hit.rect), } }) .collect(); + let selected = self.selected_row.as_ref().map(panel_e2e_row_key); + let header = panel_e2e_dashboard_header(&self.panel); crate::e2e_observer::emit( "panel", "rows_rendered", PanelE2eRowsRendered { - selected: self.selected_row.as_ref().map(panel_e2e_row_key), - rows, + selected: selected.clone(), + header: header.clone(), + rows: rows.clone(), }, ); + if !self.emitted_dashboard_content_ready { + let categories = panel_e2e_dashboard_categories(&rows); + let snapshot = PanelE2eDashboardSnapshot { header, rows }; + if panel_e2e_dashboard_content_is_ready(&snapshot, &categories) { + crate::e2e_observer::emit( + "panel", + "dashboard_content_ready", + PanelE2eDashboardContentReady { + snapshot, + categories, + }, + ); + self.emitted_dashboard_content_ready = true; + } + } } fn ensure_selection_visible(&mut self) { @@ -2286,12 +2483,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_metadata_status_probe.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 +2526,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_metadata_status_probe.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_probe", + 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 +2583,63 @@ 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_metadata_status_probe.after_orchestrator_reload", + elapsed_ms: source_started.elapsed().as_millis(), + }); } + + #[cfg(feature = "e2e-test")] + let source_started = Instant::now(); + #[cfg(feature = "e2e-test")] + let (mut panel, panel_source_timings) = + build_workspace_panel_with_e2e_timings(&workspace_root, &list); + #[cfg(not(feature = "e2e-test"))] 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.total", + elapsed_ms: source_started.elapsed().as_millis(), + }); + source_timings.extend(panel_source_timings.into_iter().map(|timing| { + PanelE2eSourceTiming { + source: timing.source, + elapsed_ms: timing.elapsed_ms, + } + })); + } + + #[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/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 1c7d59c6..a6441bc6 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(feature = "e2e-test")] +use std::time::Instant; use protocol::PodStatus; use ticket::config::{ @@ -731,6 +733,7 @@ fn git_output(worktree_root: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +#[cfg_attr(feature = "e2e-test", allow(dead_code))] pub(crate) fn build_workspace_panel( workspace_root: &Path, pods: &PodList, @@ -758,6 +761,148 @@ pub(crate) fn build_workspace_panel( build_workspace_panel_with_registry(workspace_root, pods, ®istry) } +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkspacePanelE2eSourceTiming { + pub(crate) source: &'static str, + pub(crate) elapsed_ms: u128, +} + +#[cfg(feature = "e2e-test")] +pub(crate) fn build_workspace_panel_with_e2e_timings( + workspace_root: &Path, + pods: &PodList, +) -> (WorkspacePanelViewModel, Vec) { + let mut timings = Vec::new(); + let started = Instant::now(); + let registry = match PanelRegistryStore::default_for_workspace(workspace_root) + .and_then(|store| store.snapshot()) + { + Ok(snapshot) => snapshot, + Err(error) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "local_claim_scan", + elapsed_ms: started.elapsed().as_millis(), + }); + let mut model = WorkspacePanelViewModel::empty(workspace_root); + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Panel local role registry unavailable: {error}" + ))); + return ( + build_workspace_panel_with_registry_model( + model, + workspace_root, + pods, + &PanelRegistrySnapshot::empty(), + ), + timings, + ); + } + }; + timings.push(WorkspacePanelE2eSourceTiming { + source: "local_claim_scan", + elapsed_ms: started.elapsed().as_millis(), + }); + + let mut model = WorkspacePanelViewModel::empty(workspace_root); + let started = Instant::now(); + let availability = ticket_config_availability(workspace_root); + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_config_probe", + elapsed_ms: started.elapsed().as_millis(), + }); + match availability { + TicketConfigAvailability::Absent => {} + TicketConfigAvailability::Usable => { + model.header.ticket_configured = true; + model.composer = WorkspacePanelComposer::ticket_enabled(); + let started = Instant::now(); + match TicketConfig::load_workspace(workspace_root) { + Ok(config) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_config_parse", + elapsed_ms: started.elapsed().as_millis(), + }); + model.header.ticket_root = config.backend_root().to_path_buf(); + let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()) + .with_record_language(config.ticket_record_language()); + let started = Instant::now(); + let orchestration_overlay = + load_orchestration_ticket_overlay(workspace_root, &config); + timings.push(WorkspacePanelE2eSourceTiming { + source: "orchestration_overlay_validation_read_git", + elapsed_ms: started.elapsed().as_millis(), + }); + let started = Instant::now(); + match build_ticket_rows( + &backend, + pods, + ®istry, + &orchestration_overlay.states, + ) { + Ok(ticket_rows) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_scan_parse", + elapsed_ms: started.elapsed().as_millis(), + }); + model.rows.extend(ticket_rows.rows); + model.header.diagnostics.extend(ticket_rows.diagnostics); + model + .header + .diagnostics + .extend(orchestration_overlay.diagnostics); + } + Err(error) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_scan_parse", + elapsed_ms: started.elapsed().as_millis(), + }); + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Ticket rows unavailable: {error}" + ))) + } + } + } + Err(error) => { + timings.push(WorkspacePanelE2eSourceTiming { + source: "ticket_config_parse", + elapsed_ms: started.elapsed().as_millis(), + }); + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Ticket config is unusable: {error}" + ))) + } + } + } + TicketConfigAvailability::Unusable(message) => { + model.header.ticket_configured = true; + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Ticket config is unusable: {message}" + ))); + } + } + + let started = Instant::now(); + model.rows.extend(pod_rows(pods)); + timings.push(WorkspacePanelE2eSourceTiming { + source: "pod_row_materialization", + elapsed_ms: started.elapsed().as_millis(), + }); + (model, timings) +} + fn build_workspace_panel_with_registry( workspace_root: &Path, pods: &PodList, diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index e551e9f1..f20127d3 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -264,6 +264,14 @@ pub struct RenderedPanelRow { pub title: String, pub status: Option, pub action: Option, + #[serde(default)] + pub disabled_reason: Option, + #[serde(default)] + pub local_state: Option, + #[serde(default)] + pub overlay_state: Option, + #[serde(default)] + pub overlay_detail: Option, pub rect: PanelRect, } @@ -272,6 +280,10 @@ pub struct ExpectedPanelTicketRow { pub id: String, pub title: String, pub status: String, + pub action: Option, + pub disabled_reason: Option, + pub local_state: Option, + pub overlay_state: Option, } impl ExpectedPanelTicketRow { @@ -280,24 +292,221 @@ impl ExpectedPanelTicketRow { id: id.into(), title: title.into(), status: status.into(), + action: None, + disabled_reason: None, + local_state: None, + overlay_state: None, } } + pub fn with_action(mut self, action: impl Into) -> Self { + self.action = Some(action.into()); + self + } + + pub fn with_disabled_reason(mut self, disabled_reason: impl Into) -> Self { + self.disabled_reason = Some(disabled_reason.into()); + self + } + + pub fn with_local_state(mut self, local_state: impl Into) -> Self { + self.local_state = Some(local_state.into()); + self + } + + pub fn with_overlay_state(mut self, overlay_state: impl Into) -> Self { + self.overlay_state = Some(overlay_state.into()); + self + } + pub fn matches(&self, row: &RenderedPanelRow) -> bool { row.key.kind == "ticket" && row.key.id == self.id && row.title == self.title && row.status.as_deref() == Some(self.status.as_str()) + && self.action.as_ref().map_or(true, |action| { + row.action.as_deref() == Some(action.as_str()) + }) + && self.disabled_reason.as_ref().map_or(true, |reason| { + row.disabled_reason + .as_deref() + .is_some_and(|actual| actual.contains(reason)) + }) + && self.local_state.as_ref().map_or(true, |state| { + row.local_state.as_deref() == Some(state.as_str()) + }) + && self.overlay_state.as_ref().map_or(true, |state| { + row.overlay_state.as_deref() == Some(state.as_str()) + }) } fn description(&self) -> String { format!( - "ticket row id={} title={:?} status={}", - self.id, self.title, self.status + "ticket row id={} title={:?} status={} action={:?} disabled_reason={:?} local_state={:?} overlay_state={:?}", + self.id, + self.title, + self.status, + self.action, + self.disabled_reason, + self.local_state, + self.overlay_state, ) } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExpectedDashboardContent { + pub tickets: Vec, + pub pod_names: Vec, + pub companion_status: String, + pub orchestrator_status: String, +} + +impl ExpectedDashboardContent { + pub fn snapshot(&self) -> DashboardContentSnapshot { + DashboardContentSnapshot { + tickets: self.tickets.clone(), + pod_names: self.pod_names.clone(), + companion_status: self.companion_status.clone(), + orchestrator_status: self.orchestrator_status.clone(), + } + } + + 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}] companion={} orchestrator={}", + self.companion_status, self.orchestrator_status + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DashboardContentSnapshot { + pub tickets: Vec, + pub pod_names: Vec, + pub companion_status: String, + pub orchestrator_status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardContentReady { + pub snapshot: DashboardSnapshot, + pub categories: DashboardContentCategories, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardSnapshot { + pub header: DashboardHeader, + pub rows: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardHeader { + pub ticket_configured: bool, + pub companion: Option, + pub orchestrator: Option, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardCompanionState { + pub pod_name: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardOrchestratorState { + pub pod_name: String, + pub status: String, + pub detail: Option, +} + +#[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: None, + rows: self.snapshot.rows.clone(), + } + } + + pub fn snapshot_for_expected( + &self, + expected: &ExpectedDashboardContent, + ) -> DashboardContentSnapshot { + DashboardContentSnapshot { + tickets: expected + .tickets + .iter() + .filter(|ticket| self.snapshot.rows.iter().any(|row| ticket.matches(row))) + .cloned() + .collect(), + pod_names: expected + .pod_names + .iter() + .filter(|pod_name| { + self.snapshot + .rows + .iter() + .any(|row| row.key.kind == "pod" && row.key.id == pod_name.as_str()) + }) + .cloned() + .collect(), + companion_status: self + .snapshot + .header + .companion + .as_ref() + .map(|companion| companion.status.clone()) + .unwrap_or_default(), + orchestrator_status: self + .snapshot + .header + .orchestrator + .as_ref() + .map(|orchestrator| orchestrator.status.clone()) + .unwrap_or_default(), + } + } +} + +#[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 +751,49 @@ 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 = expected.snapshot(); + 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, @@ -1029,6 +1281,7 @@ impl FixtureWorkspace { )?; fixture.ready_ticket_id = first; fixture.planning_ticket_id = second; + fixture.setup_orchestration_overlay(binary)?; fixture.write_fixture_metadata("ready", None)?; Ok(fixture) } @@ -1039,6 +1292,95 @@ impl FixtureWorkspace { READY_FIXTURE_TICKET_TITLE, "ready", ) + .with_action("Queue") + .with_local_state("ready") + } + + pub fn ready_overlay_ticket_row(&self) -> ExpectedPanelTicketRow { + ExpectedPanelTicketRow::new( + self.ready_ticket_id.clone(), + READY_FIXTURE_TICKET_TITLE, + "ready→prog", + ) + .with_action("Wait") + .with_disabled_reason("orchestration worktree overlay shows Ticket state inprogress") + .with_local_state("ready") + .with_overlay_state("inprogress") + } + + pub fn planning_fixture_ticket_row(&self) -> ExpectedPanelTicketRow { + ExpectedPanelTicketRow::new( + self.planning_ticket_id.clone(), + PLANNING_FIXTURE_TICKET_TITLE, + "planning", + ) + .with_action("Clarify") + .with_disabled_reason("Ticket is still in planning") + .with_local_state("planning") + } + + pub fn expected_dashboard_content(&self) -> ExpectedDashboardContent { + ExpectedDashboardContent { + tickets: vec![ + self.ready_overlay_ticket_row(), + self.planning_fixture_ticket_row(), + ], + pod_names: vec!["workspace".to_string()], + companion_status: "unavailable".to_string(), + orchestrator_status: "unavailable".to_string(), + } + } + + fn setup_orchestration_overlay(&self, binary: &Path) -> Result<()> { + run_git(&self.workspace, &["init"])?; + run_git(&self.workspace, &["checkout", "-B", "develop"])?; + run_git( + &self.workspace, + &["config", "user.email", "fixture@example.invalid"], + )?; + run_git(&self.workspace, &["config", "user.name", "Yoi E2E Fixture"])?; + run_git(&self.workspace, &["add", ".yoi"])?; + run_git(&self.workspace, &["commit", "-m", "fixture tickets"])?; + let orchestration = self.workspace.join(".worktree/orchestration"); + run_git( + &self.workspace, + &[ + "worktree", + "add", + "-b", + "orchestration", + orchestration.to_string_lossy().as_ref(), + "HEAD", + ], + )?; + run_yoi( + binary, + &orchestration, + &self.home, + &self.xdg_data_home, + &self.xdg_state_home, + &self.xdg_config_home, + &self.xdg_runtime_dir, + &self.artifacts_dir, + &["ticket", "state", &self.ready_ticket_id, "queued"], + )?; + run_yoi( + binary, + &orchestration, + &self.home, + &self.xdg_data_home, + &self.xdg_state_home, + &self.xdg_config_home, + &self.xdg_runtime_dir, + &self.artifacts_dir, + &["ticket", "state", &self.ready_ticket_id, "inprogress"], + )?; + run_git(&orchestration, &["add", ".yoi"])?; + run_git( + &orchestration, + &["commit", "-m", "fixture orchestration overlay"], + )?; + Ok(()) } pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig { @@ -1379,6 +1721,23 @@ fn create_ticket( .ok_or_else(|| HarnessError::Protocol(format!("could not parse ticket id from {output:?}"))) } +fn run_git(workspace: &Path, args: &[&str]) -> Result<()> { + let output = Command::new("git") + .args(args) + .current_dir(workspace) + .output()?; + if output.status.success() { + return Ok(()); + } + Err(HarnessError::CommandFailed { + program: PathBuf::from("git"), + args: args.iter().map(|arg| (*arg).to_string()).collect(), + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + fn run_yoi( binary: &Path, workspace: &Path, diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 09845e11..c755ef22 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -1,31 +1,109 @@ 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::{ + DashboardCompanionState, DashboardContentCategories, DashboardContentReady, DashboardHeader, + DashboardOrchestratorState, DashboardSnapshot, ExpectedDashboardContent, ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary, }; -#[test] -fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { - let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready"); - let wrong_title = RenderedPanelRow { +fn rendered_ticket_row( + id: &str, + title: &str, + status: &str, + action: Option<&str>, + disabled_reason: Option<&str>, + local_state: Option<&str>, + overlay_state: Option<&str>, +) -> RenderedPanelRow { + RenderedPanelRow { key: PanelRowKey { kind: "ticket".to_string(), - id: "0000000000000".to_string(), + id: id.to_string(), }, - title: "Different Ticket".to_string(), - status: Some("ready".to_string()), - action: None, + title: title.to_string(), + status: Some(status.to_string()), + action: action.map(ToOwned::to_owned), + disabled_reason: disabled_reason.map(ToOwned::to_owned), + local_state: local_state.map(ToOwned::to_owned), + overlay_state: overlay_state.map(ToOwned::to_owned), + overlay_detail: overlay_state.map(|state| format!("orchestration:{state}")), rect: PanelRect { x: 0, y: 0, width: 10, height: 1, }, - }; + } +} + +fn rendered_pod_row(name: &str) -> RenderedPanelRow { + RenderedPanelRow { + key: PanelRowKey { + kind: "pod".to_string(), + id: name.to_string(), + }, + title: name.to_string(), + status: None, + action: None, + disabled_reason: None, + local_state: None, + overlay_state: None, + overlay_detail: None, + rect: PanelRect { + x: 0, + y: 1, + width: 10, + height: 1, + }, + } +} + +fn ready_snapshot(rows: Vec) -> DashboardContentReady { + DashboardContentReady { + snapshot: DashboardSnapshot { + header: DashboardHeader { + ticket_configured: true, + companion: Some(DashboardCompanionState { + pod_name: "workspace".to_string(), + status: "unavailable".to_string(), + }), + orchestrator: Some(DashboardOrchestratorState { + pod_name: "workspace-orchestrator".to_string(), + status: "unavailable".to_string(), + detail: Some("fixture blocks host Pod launch".to_string()), + }), + diagnostics: vec![], + }, + rows, + }, + categories: DashboardContentCategories { + ticket_rows: 2, + ready_ticket_rows: 1, + planning_ticket_rows: 1, + pod_rows: 1, + actionable_rows: 2, + }, + } +} + +#[test] +fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { + let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready") + .with_action("Queue") + .with_local_state("ready"); + let wrong_title = rendered_ticket_row( + "0000000000000", + "Different Ticket", + "ready", + Some("Queue"), + None, + Some("ready"), + None, + ); let wrong_kind = RenderedPanelRow { key: PanelRowKey { kind: "pod".to_string(), @@ -33,7 +111,11 @@ fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { }, title: "Ready E2E Ticket".to_string(), status: Some("ready".to_string()), - action: None, + action: Some("Queue".to_string()), + disabled_reason: None, + local_state: Some("ready".to_string()), + overlay_state: None, + overlay_detail: None, rect: PanelRect { x: 0, y: 0, @@ -51,6 +133,91 @@ fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { assert!(!rows.has_fixture_ticket_row(&expected)); } +#[test] +fn dashboard_snapshot_rejects_missing_row_wrong_state_missing_overlay_and_missing_action() { + let expected_ready = ExpectedPanelTicketRow::new("ready-id", "Ready E2E Ticket", "ready→prog") + .with_action("Wait") + .with_disabled_reason("orchestration worktree overlay shows Ticket state inprogress") + .with_local_state("ready") + .with_overlay_state("inprogress"); + let expected_planning = + ExpectedPanelTicketRow::new("planning-id", "Planning E2E Ticket", "planning") + .with_action("Clarify") + .with_disabled_reason("Ticket is still in planning") + .with_local_state("planning"); + let expected = ExpectedDashboardContent { + tickets: vec![expected_ready.clone(), expected_planning.clone()], + pod_names: vec!["workspace".to_string()], + companion_status: "unavailable".to_string(), + orchestrator_status: "unavailable".to_string(), + }; + let complete_rows = || { + vec![ + rendered_ticket_row( + "ready-id", + "Ready E2E Ticket", + "ready→prog", + Some("Wait"), + Some("orchestration worktree overlay shows Ticket state inprogress"), + Some("ready"), + Some("inprogress"), + ), + rendered_ticket_row( + "planning-id", + "Planning E2E Ticket", + "planning", + Some("Clarify"), + Some("Ticket is still in planning"), + Some("planning"), + None, + ), + rendered_pod_row("workspace"), + ] + }; + assert_eq!( + ready_snapshot(complete_rows()).snapshot_for_expected(&expected), + expected.snapshot() + ); + + let missing_row = ready_snapshot(vec![ + rendered_ticket_row( + "ready-id", + "Ready E2E Ticket", + "ready→prog", + Some("Wait"), + Some("orchestration worktree overlay shows Ticket state inprogress"), + Some("ready"), + Some("inprogress"), + ), + rendered_pod_row("workspace"), + ]); + assert_ne!( + missing_row.snapshot_for_expected(&expected), + expected.snapshot() + ); + + let mut wrong_state_rows = complete_rows(); + wrong_state_rows[0].status = Some("ready".to_string()); + assert_ne!( + ready_snapshot(wrong_state_rows).snapshot_for_expected(&expected), + expected.snapshot() + ); + + let mut missing_overlay_rows = complete_rows(); + missing_overlay_rows[0].overlay_state = None; + assert_ne!( + ready_snapshot(missing_overlay_rows).snapshot_for_expected(&expected), + expected.snapshot() + ); + + let mut missing_action_rows = complete_rows(); + missing_action_rows[0].action = None; + assert_ne!( + ready_snapshot(missing_action_rows).snapshot_for_expected(&expected), + expected.snapshot() + ); +} + #[test] fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> { let binary = yoi_binary()?; @@ -114,11 +281,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 +304,63 @@ 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.snapshot.header.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.snapshot.header.companion.is_some() + && content_ready.snapshot.header.orchestrator.is_some(), + "dashboard content ready must include Companion and Orchestrator header status; got {:?}; artifacts at {}", + content_ready.snapshot.header, + panel.artifacts().dir.display() + ); + assert_eq!( + content_ready.snapshot_for_expected(&expected_content), + expected_content.snapshot(), + "dashboard content ready must match expected Ticket/action/overlay/header snapshot; artifacts at {}", + panel.artifacts().dir.display() + ); + 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_metadata_status_probe.initial") + && source_breakdown.has_source("ticket_config_probe") + && source_breakdown.has_source("local_claim_scan") + && source_breakdown.has_source("ticket_scan_parse") + && source_breakdown.has_source("orchestration_overlay_validation_read_git") + && source_breakdown.has_source("workspace_panel.build.total"), + "dashboard source breakdown should include pod metadata/status, ticket scan/parse, overlay validation/read/git, local claim scan, 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() );