From 5870251bdfb3a6cf04cda86a105bdbc33a23bb00 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 18 Jun 2026 23:39:52 +0900 Subject: [PATCH] tui: tighten panel dashboard readiness --- crates/tui/src/multi_pod.rs | 189 ++++++++++++++++++++----- crates/tui/src/workspace_panel.rs | 145 +++++++++++++++++++ tests/e2e/src/lib.rs | 227 ++++++++++++++++++++++++++++-- tests/e2e/tests/panel.rs | 213 ++++++++++++++++++++++++++-- 4 files changed, 709 insertions(+), 65 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 9d52e492..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; @@ -947,6 +951,10 @@ struct PanelE2eRenderedRow { title: String, status: Option, action: Option<&'static str>, + disabled_reason: Option, + local_state: Option, + overlay_state: Option, + overlay_detail: Option, rect: PanelE2eRect, } @@ -954,16 +962,45 @@ struct PanelE2eRenderedRow { #[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 { - ticket_configured: bool, - selected: Option, + snapshot: PanelE2eDashboardSnapshot, categories: PanelE2eDashboardCategories, - diagnostics: Vec, +} + +#[cfg(feature = "e2e-test")] +#[derive(Debug, Clone, Serialize)] +struct PanelE2eDashboardSnapshot { + header: PanelE2eDashboardHeader, rows: Vec, } @@ -1035,26 +1072,68 @@ fn panel_e2e_dashboard_categories(rows: &[PanelE2eRenderedRow]) -> PanelE2eDashb 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")) + .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.status.as_deref() == Some("planning")) + .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( - ticket_configured: bool, + snapshot: &PanelE2eDashboardSnapshot, categories: &PanelE2eDashboardCategories, ) -> bool { - ticket_configured + 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 { @@ -1430,19 +1509,46 @@ impl MultiPodApp { .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) } }, }; @@ -1451,34 +1557,35 @@ 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: selected.clone(), + header: header.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, - ) { + let snapshot = PanelE2eDashboardSnapshot { header, rows }; + if panel_e2e_dashboard_content_is_ready(&snapshot, &categories) { crate::e2e_observer::emit( "panel", "dashboard_content_ready", PanelE2eDashboardContentReady { - ticket_configured: self.panel.header.ticket_configured, - selected, + snapshot, categories, - diagnostics: self.panel.header.diagnostics.clone(), - rows, }, ); self.emitted_dashboard_content_ready = true; @@ -2390,7 +2497,7 @@ async fn load_multi_pod_snapshot( 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", + source: "pod_metadata_status_probe.initial", elapsed_ms: source_started.elapsed().as_millis(), }); @@ -2430,7 +2537,7 @@ async fn load_multi_pod_snapshot( 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", + source: "pod_metadata_status_probe.after_companion_reload", elapsed_ms: source_started.elapsed().as_millis(), }); } @@ -2440,7 +2547,7 @@ async fn load_multi_pod_snapshot( let config = ticket_config_availability(&workspace_root); #[cfg(feature = "e2e-test")] source_timings.push(PanelE2eSourceTiming { - source: "ticket_config", + source: "ticket_config_probe", elapsed_ms: source_started.elapsed().as_millis(), }); @@ -2487,23 +2594,35 @@ async fn load_multi_pod_snapshot( list = load_pod_list(list_selected_name, MAX_ENTRIES).await?; #[cfg(feature = "e2e-test")] source_timings.push(PanelE2eSourceTiming { - source: "pod_list.after_orchestrator_reload", + 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", - elapsed_ms: source_started.elapsed().as_millis(), - }); + { + 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( 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 682659c0..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,20 +292,64 @@ 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, ) } } @@ -302,9 +358,20 @@ impl ExpectedPanelTicketRow { 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 @@ -313,7 +380,10 @@ impl ExpectedDashboardContent { .collect::>() .join(", "); let pods = self.pod_names.join(", "); - format!("tickets=[{tickets}] pods=[{pods}]") + format!( + "tickets=[{tickets}] pods=[{pods}] companion={} orchestrator={}", + self.companion_status, self.orchestrator_status + ) } } @@ -321,16 +391,42 @@ impl ExpectedDashboardContent { 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 ticket_configured: bool, - pub selected: Option, + 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, - pub rows: 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)] @@ -345,8 +441,8 @@ pub struct DashboardContentCategories { impl DashboardContentReady { pub fn rows_rendered(&self) -> RowsRendered { RowsRendered { - selected: self.selected.clone(), - rows: self.rows.clone(), + selected: None, + rows: self.snapshot.rows.clone(), } } @@ -358,19 +454,34 @@ impl DashboardContentReady { tickets: expected .tickets .iter() - .filter(|ticket| self.rows.iter().any(|row| ticket.matches(row))) + .filter(|ticket| self.snapshot.rows.iter().any(|row| ticket.matches(row))) .cloned() .collect(), pod_names: expected .pod_names .iter() .filter(|pod_name| { - self.rows + 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(), } } } @@ -649,10 +760,7 @@ impl PanelHarness { expected: &ExpectedDashboardContent, timeout: Duration, ) -> Result { - let expected_snapshot = DashboardContentSnapshot { - tickets: expected.tickets.clone(), - pod_names: expected.pod_names.clone(), - }; + let expected_snapshot = expected.snapshot(); let description = expected.description(); let event = self.wait_for( format!("dashboard content ready ({description})"), @@ -1173,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) } @@ -1183,6 +1292,20 @@ 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 { @@ -1191,18 +1314,75 @@ impl FixtureWorkspace { 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_fixture_ticket_row(), + 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 { PanelHarnessConfig { binary, @@ -1541,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 41a04768..c755ef22 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -4,28 +4,106 @@ const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500); 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()?; @@ -143,10 +310,23 @@ fn panel_dashboard_content_ready_has_startup_budget() -> yoi_e2e::Result<()> { let content_ready = panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?; assert!( - content_ready.ticket_configured, + content_ready.snapshot.header.ticket_configured, "dashboard content ready must include usable Ticket configuration; artifacts at {}", panel.artifacts().dir.display() ); + 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 @@ -168,10 +348,13 @@ fn panel_dashboard_content_ready_has_startup_budget() -> yoi_e2e::Result<()> { 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.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() );