tui: tighten panel dashboard readiness

This commit is contained in:
Keisuke Hirata 2026-06-18 23:39:52 +09:00
parent fc1ee5bb55
commit 5870251bdf
No known key found for this signature in database
4 changed files with 709 additions and 65 deletions

View File

@ -44,15 +44,19 @@ use crate::pod_list::{
use crate::role_session_registry::{ use crate::role_session_registry::{
PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin, TicketClaimResult, 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::{ use crate::workspace_panel::{
ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus, ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus,
CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan, CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus, PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus,
WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row, WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row,
build_workspace_panel, companion_pod_presence, decide_companion_lifecycle, companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle,
decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence, local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability,
ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name, workspace_companion_pod_name, workspace_orchestrator_pod_name,
}; };
const MAX_ENTRIES: usize = 50; const MAX_ENTRIES: usize = 50;
@ -947,6 +951,10 @@ struct PanelE2eRenderedRow {
title: String, title: String,
status: Option<String>, status: Option<String>,
action: Option<&'static str>, action: Option<&'static str>,
disabled_reason: Option<String>,
local_state: Option<String>,
overlay_state: Option<String>,
overlay_detail: Option<String>,
rect: PanelE2eRect, rect: PanelE2eRect,
} }
@ -954,16 +962,45 @@ struct PanelE2eRenderedRow {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
struct PanelE2eRowsRendered { struct PanelE2eRowsRendered {
selected: Option<PanelE2eRowKey>, selected: Option<PanelE2eRowKey>,
header: PanelE2eDashboardHeader,
rows: Vec<PanelE2eRenderedRow>, rows: Vec<PanelE2eRenderedRow>,
} }
#[cfg(feature = "e2e-test")]
#[derive(Debug, Clone, Serialize)]
struct PanelE2eDashboardHeader {
ticket_configured: bool,
companion: Option<PanelE2eCompanionState>,
orchestrator: Option<PanelE2eOrchestratorState>,
diagnostics: Vec<String>,
}
#[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<String>,
}
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct PanelE2eDashboardContentReady { struct PanelE2eDashboardContentReady {
ticket_configured: bool, snapshot: PanelE2eDashboardSnapshot,
selected: Option<PanelE2eRowKey>,
categories: PanelE2eDashboardCategories, categories: PanelE2eDashboardCategories,
diagnostics: Vec<String>, }
#[cfg(feature = "e2e-test")]
#[derive(Debug, Clone, Serialize)]
struct PanelE2eDashboardSnapshot {
header: PanelE2eDashboardHeader,
rows: Vec<PanelE2eRenderedRow>, rows: Vec<PanelE2eRenderedRow>,
} }
@ -1035,26 +1072,68 @@ fn panel_e2e_dashboard_categories(rows: &[PanelE2eRenderedRow]) -> PanelE2eDashb
ticket_rows: rows.iter().filter(|row| row.key.kind == "ticket").count(), ticket_rows: rows.iter().filter(|row| row.key.kind == "ticket").count(),
ready_ticket_rows: rows ready_ticket_rows: rows
.iter() .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(), .count(),
planning_ticket_rows: rows planning_ticket_rows: rows
.iter() .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(), .count(),
pod_rows: rows.iter().filter(|row| row.key.kind == "pod").count(), pod_rows: rows.iter().filter(|row| row.key.kind == "pod").count(),
actionable_rows: rows.iter().filter(|row| row.action.is_some()).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")] #[cfg(feature = "e2e-test")]
fn panel_e2e_dashboard_content_is_ready( fn panel_e2e_dashboard_content_is_ready(
ticket_configured: bool, snapshot: &PanelE2eDashboardSnapshot,
categories: &PanelE2eDashboardCategories, categories: &PanelE2eDashboardCategories,
) -> bool { ) -> bool {
ticket_configured snapshot.header.ticket_configured
&& snapshot.header.companion.is_some()
&& snapshot.header.orchestrator.is_some()
&& categories.ready_ticket_rows > 0 && categories.ready_ticket_rows > 0
&& categories.planning_ticket_rows > 0 && categories.planning_ticket_rows > 0
&& categories.pod_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) struct MultiPodApp {
@ -1430,19 +1509,46 @@ impl MultiPodApp {
.iter() .iter()
.map(|hit| { .map(|hit| {
let panel_row = self.panel.row(&hit.key); let panel_row = self.panel.row(&hit.key);
let (title, status, action) = match panel_row { let (
Some(row) => ( title,
row.title.clone(), status,
Some(row.status.clone()), action,
row.next_action.map(NextUserAction::label), 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 { 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) => { PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => {
(id.clone(), None, None) (id.clone(), None, None, None, None, None, None)
} }
PanelRowKey::TicketIntakePod { pod_name, .. } => { 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, title,
status, status,
action, action,
disabled_reason,
local_state,
overlay_state,
overlay_detail,
rect: panel_e2e_rect(hit.rect), rect: panel_e2e_rect(hit.rect),
} }
}) })
.collect(); .collect();
let selected = self.selected_row.as_ref().map(panel_e2e_row_key); let selected = self.selected_row.as_ref().map(panel_e2e_row_key);
let header = panel_e2e_dashboard_header(&self.panel);
crate::e2e_observer::emit( crate::e2e_observer::emit(
"panel", "panel",
"rows_rendered", "rows_rendered",
PanelE2eRowsRendered { PanelE2eRowsRendered {
selected: selected.clone(), selected: selected.clone(),
header: header.clone(),
rows: rows.clone(), rows: rows.clone(),
}, },
); );
if !self.emitted_dashboard_content_ready { if !self.emitted_dashboard_content_ready {
let categories = panel_e2e_dashboard_categories(&rows); let categories = panel_e2e_dashboard_categories(&rows);
if panel_e2e_dashboard_content_is_ready( let snapshot = PanelE2eDashboardSnapshot { header, rows };
self.panel.header.ticket_configured, if panel_e2e_dashboard_content_is_ready(&snapshot, &categories) {
&categories,
) {
crate::e2e_observer::emit( crate::e2e_observer::emit(
"panel", "panel",
"dashboard_content_ready", "dashboard_content_ready",
PanelE2eDashboardContentReady { PanelE2eDashboardContentReady {
ticket_configured: self.panel.header.ticket_configured, snapshot,
selected,
categories, categories,
diagnostics: self.panel.header.diagnostics.clone(),
rows,
}, },
); );
self.emitted_dashboard_content_ready = true; 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?; let mut list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { source_timings.push(PanelE2eSourceTiming {
source: "pod_list.initial", source: "pod_metadata_status_probe.initial",
elapsed_ms: source_started.elapsed().as_millis(), 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?; list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { 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(), elapsed_ms: source_started.elapsed().as_millis(),
}); });
} }
@ -2440,7 +2547,7 @@ async fn load_multi_pod_snapshot(
let config = ticket_config_availability(&workspace_root); let config = ticket_config_availability(&workspace_root);
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { source_timings.push(PanelE2eSourceTiming {
source: "ticket_config", source: "ticket_config_probe",
elapsed_ms: source_started.elapsed().as_millis(), 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?; list = load_pod_list(list_selected_name, MAX_ENTRIES).await?;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { 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(), elapsed_ms: source_started.elapsed().as_millis(),
}); });
} }
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
let source_started = Instant::now(); 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); let mut panel = build_workspace_panel(&workspace_root, &list);
panel.header.companion = companion.state; panel.header.companion = companion.state;
panel.header.diagnostics.extend(companion.diagnostics); panel.header.diagnostics.extend(companion.diagnostics);
panel.header.orchestrator = orchestrator.state; panel.header.orchestrator = orchestrator.state;
panel.header.diagnostics.extend(orchestrator.diagnostics); panel.header.diagnostics.extend(orchestrator.diagnostics);
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { {
source: "workspace_panel.build", source_timings.push(PanelE2eSourceTiming {
elapsed_ms: source_started.elapsed().as_millis(), 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")] #[cfg(feature = "e2e-test")]
crate::e2e_observer::emit( crate::e2e_observer::emit(

View File

@ -1,6 +1,8 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
#[cfg(feature = "e2e-test")]
use std::time::Instant;
use protocol::PodStatus; use protocol::PodStatus;
use ticket::config::{ use ticket::config::{
@ -731,6 +733,7 @@ fn git_output(worktree_root: &Path, args: &[&str]) -> Result<String, String> {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} }
#[cfg_attr(feature = "e2e-test", allow(dead_code))]
pub(crate) fn build_workspace_panel( pub(crate) fn build_workspace_panel(
workspace_root: &Path, workspace_root: &Path,
pods: &PodList, pods: &PodList,
@ -758,6 +761,148 @@ pub(crate) fn build_workspace_panel(
build_workspace_panel_with_registry(workspace_root, pods, &registry) build_workspace_panel_with_registry(workspace_root, pods, &registry)
} }
#[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<WorkspacePanelE2eSourceTiming>) {
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,
&registry,
&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( fn build_workspace_panel_with_registry(
workspace_root: &Path, workspace_root: &Path,
pods: &PodList, pods: &PodList,

View File

@ -264,6 +264,14 @@ pub struct RenderedPanelRow {
pub title: String, pub title: String,
pub status: Option<String>, pub status: Option<String>,
pub action: Option<String>, pub action: Option<String>,
#[serde(default)]
pub disabled_reason: Option<String>,
#[serde(default)]
pub local_state: Option<String>,
#[serde(default)]
pub overlay_state: Option<String>,
#[serde(default)]
pub overlay_detail: Option<String>,
pub rect: PanelRect, pub rect: PanelRect,
} }
@ -272,6 +280,10 @@ pub struct ExpectedPanelTicketRow {
pub id: String, pub id: String,
pub title: String, pub title: String,
pub status: String, pub status: String,
pub action: Option<String>,
pub disabled_reason: Option<String>,
pub local_state: Option<String>,
pub overlay_state: Option<String>,
} }
impl ExpectedPanelTicketRow { impl ExpectedPanelTicketRow {
@ -280,20 +292,64 @@ impl ExpectedPanelTicketRow {
id: id.into(), id: id.into(),
title: title.into(), title: title.into(),
status: status.into(), status: status.into(),
action: None,
disabled_reason: None,
local_state: None,
overlay_state: None,
} }
} }
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into());
self
}
pub fn with_disabled_reason(mut self, disabled_reason: impl Into<String>) -> Self {
self.disabled_reason = Some(disabled_reason.into());
self
}
pub fn with_local_state(mut self, local_state: impl Into<String>) -> Self {
self.local_state = Some(local_state.into());
self
}
pub fn with_overlay_state(mut self, overlay_state: impl Into<String>) -> Self {
self.overlay_state = Some(overlay_state.into());
self
}
pub fn matches(&self, row: &RenderedPanelRow) -> bool { pub fn matches(&self, row: &RenderedPanelRow) -> bool {
row.key.kind == "ticket" row.key.kind == "ticket"
&& row.key.id == self.id && row.key.id == self.id
&& row.title == self.title && row.title == self.title
&& row.status.as_deref() == Some(self.status.as_str()) && 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 { fn description(&self) -> String {
format!( format!(
"ticket row id={} title={:?} status={}", "ticket row id={} title={:?} status={} action={:?} disabled_reason={:?} local_state={:?} overlay_state={:?}",
self.id, self.title, self.status 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 struct ExpectedDashboardContent {
pub tickets: Vec<ExpectedPanelTicketRow>, pub tickets: Vec<ExpectedPanelTicketRow>,
pub pod_names: Vec<String>, pub pod_names: Vec<String>,
pub companion_status: String,
pub orchestrator_status: String,
} }
impl ExpectedDashboardContent { 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 { fn description(&self) -> String {
let tickets = self let tickets = self
.tickets .tickets
@ -313,7 +380,10 @@ impl ExpectedDashboardContent {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
let pods = self.pod_names.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 struct DashboardContentSnapshot {
pub tickets: Vec<ExpectedPanelTicketRow>, pub tickets: Vec<ExpectedPanelTicketRow>,
pub pod_names: Vec<String>, pub pod_names: Vec<String>,
pub companion_status: String,
pub orchestrator_status: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardContentReady { pub struct DashboardContentReady {
pub ticket_configured: bool, pub snapshot: DashboardSnapshot,
pub selected: Option<PanelRowKey>,
pub categories: DashboardContentCategories, pub categories: DashboardContentCategories,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardSnapshot {
pub header: DashboardHeader,
pub rows: Vec<RenderedPanelRow>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardHeader {
pub ticket_configured: bool,
pub companion: Option<DashboardCompanionState>,
pub orchestrator: Option<DashboardOrchestratorState>,
#[serde(default)] #[serde(default)]
pub diagnostics: Vec<String>, pub diagnostics: Vec<String>,
pub rows: Vec<RenderedPanelRow>, }
#[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<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -345,8 +441,8 @@ pub struct DashboardContentCategories {
impl DashboardContentReady { impl DashboardContentReady {
pub fn rows_rendered(&self) -> RowsRendered { pub fn rows_rendered(&self) -> RowsRendered {
RowsRendered { RowsRendered {
selected: self.selected.clone(), selected: None,
rows: self.rows.clone(), rows: self.snapshot.rows.clone(),
} }
} }
@ -358,19 +454,34 @@ impl DashboardContentReady {
tickets: expected tickets: expected
.tickets .tickets
.iter() .iter()
.filter(|ticket| self.rows.iter().any(|row| ticket.matches(row))) .filter(|ticket| self.snapshot.rows.iter().any(|row| ticket.matches(row)))
.cloned() .cloned()
.collect(), .collect(),
pod_names: expected pod_names: expected
.pod_names .pod_names
.iter() .iter()
.filter(|pod_name| { .filter(|pod_name| {
self.rows self.snapshot
.rows
.iter() .iter()
.any(|row| row.key.kind == "pod" && row.key.id == pod_name.as_str()) .any(|row| row.key.kind == "pod" && row.key.id == pod_name.as_str())
}) })
.cloned() .cloned()
.collect(), .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, expected: &ExpectedDashboardContent,
timeout: Duration, timeout: Duration,
) -> Result<DashboardContentReady> { ) -> Result<DashboardContentReady> {
let expected_snapshot = DashboardContentSnapshot { let expected_snapshot = expected.snapshot();
tickets: expected.tickets.clone(),
pod_names: expected.pod_names.clone(),
};
let description = expected.description(); let description = expected.description();
let event = self.wait_for( let event = self.wait_for(
format!("dashboard content ready ({description})"), format!("dashboard content ready ({description})"),
@ -1173,6 +1281,7 @@ impl FixtureWorkspace {
)?; )?;
fixture.ready_ticket_id = first; fixture.ready_ticket_id = first;
fixture.planning_ticket_id = second; fixture.planning_ticket_id = second;
fixture.setup_orchestration_overlay(binary)?;
fixture.write_fixture_metadata("ready", None)?; fixture.write_fixture_metadata("ready", None)?;
Ok(fixture) Ok(fixture)
} }
@ -1183,6 +1292,20 @@ impl FixtureWorkspace {
READY_FIXTURE_TICKET_TITLE, READY_FIXTURE_TICKET_TITLE,
"ready", "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 { pub fn planning_fixture_ticket_row(&self) -> ExpectedPanelTicketRow {
@ -1191,18 +1314,75 @@ impl FixtureWorkspace {
PLANNING_FIXTURE_TICKET_TITLE, PLANNING_FIXTURE_TICKET_TITLE,
"planning", "planning",
) )
.with_action("Clarify")
.with_disabled_reason("Ticket is still in planning")
.with_local_state("planning")
} }
pub fn expected_dashboard_content(&self) -> ExpectedDashboardContent { pub fn expected_dashboard_content(&self) -> ExpectedDashboardContent {
ExpectedDashboardContent { ExpectedDashboardContent {
tickets: vec![ tickets: vec![
self.ready_fixture_ticket_row(), self.ready_overlay_ticket_row(),
self.planning_fixture_ticket_row(), self.planning_fixture_ticket_row(),
], ],
pod_names: vec!["workspace".to_string()], 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 { pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig {
PanelHarnessConfig { PanelHarnessConfig {
binary, binary,
@ -1541,6 +1721,23 @@ fn create_ticket(
.ok_or_else(|| HarnessError::Protocol(format!("could not parse ticket id from {output:?}"))) .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( fn run_yoi(
binary: &Path, binary: &Path,
workspace: &Path, workspace: &Path,

View File

@ -4,28 +4,106 @@ const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500);
const DASHBOARD_CONTENT_READY_BUDGET: Duration = Duration::from_secs(5); const DASHBOARD_CONTENT_READY_BUDGET: Duration = Duration::from_secs(5);
use yoi_e2e::{ use yoi_e2e::{
DashboardCompanionState, DashboardContentCategories, DashboardContentReady, DashboardHeader,
DashboardOrchestratorState, DashboardSnapshot, ExpectedDashboardContent,
ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness,
PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary, PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary,
}; };
#[test] fn rendered_ticket_row(
fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() { id: &str,
let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready"); title: &str,
let wrong_title = RenderedPanelRow { status: &str,
action: Option<&str>,
disabled_reason: Option<&str>,
local_state: Option<&str>,
overlay_state: Option<&str>,
) -> RenderedPanelRow {
RenderedPanelRow {
key: PanelRowKey { key: PanelRowKey {
kind: "ticket".to_string(), kind: "ticket".to_string(),
id: "0000000000000".to_string(), id: id.to_string(),
}, },
title: "Different Ticket".to_string(), title: title.to_string(),
status: Some("ready".to_string()), status: Some(status.to_string()),
action: None, 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 { rect: PanelRect {
x: 0, x: 0,
y: 0, y: 0,
width: 10, width: 10,
height: 1, 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<RenderedPanelRow>) -> 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 { let wrong_kind = RenderedPanelRow {
key: PanelRowKey { key: PanelRowKey {
kind: "pod".to_string(), kind: "pod".to_string(),
@ -33,7 +111,11 @@ fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() {
}, },
title: "Ready E2E Ticket".to_string(), title: "Ready E2E Ticket".to_string(),
status: Some("ready".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 { rect: PanelRect {
x: 0, x: 0,
y: 0, y: 0,
@ -51,6 +133,91 @@ fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() {
assert!(!rows.has_fixture_ticket_row(&expected)); 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] #[test]
fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> { fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> {
let binary = yoi_binary()?; let binary = yoi_binary()?;
@ -143,10 +310,23 @@ fn panel_dashboard_content_ready_has_startup_budget() -> yoi_e2e::Result<()> {
let content_ready = let content_ready =
panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?; panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?;
assert!( assert!(
content_ready.ticket_configured, content_ready.snapshot.header.ticket_configured,
"dashboard content ready must include usable Ticket configuration; artifacts at {}", "dashboard content ready must include usable Ticket configuration; artifacts at {}",
panel.artifacts().dir.display() 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!( assert!(
content_ready.categories.ready_ticket_rows > 0 content_ready.categories.ready_ticket_rows > 0
&& content_ready.categories.planning_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()?; let source_breakdown = panel.expect_dashboard_source_breakdown()?;
assert!( assert!(
source_breakdown.has_source("pod_list.initial") source_breakdown.has_source("pod_metadata_status_probe.initial")
&& source_breakdown.has_source("ticket_config") && source_breakdown.has_source("ticket_config_probe")
&& source_breakdown.has_source("workspace_panel.build"), && source_breakdown.has_source("local_claim_scan")
"dashboard source breakdown should include pod, ticket, and panel-build sources; got {:?}; artifacts at {}", && 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, source_breakdown,
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );