tui: measure panel dashboard readiness

This commit is contained in:
Keisuke Hirata 2026-06-18 23:13:06 +09:00
parent 3b4879446f
commit fc1ee5bb55
No known key found for this signature in database
3 changed files with 386 additions and 19 deletions

View File

@ -925,14 +925,14 @@ impl PanelRowHitBox {
} }
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
struct PanelE2eRowKey { struct PanelE2eRowKey {
kind: &'static str, kind: &'static str,
id: String, id: String,
} }
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
struct PanelE2eRect { struct PanelE2eRect {
x: u16, x: u16,
y: u16, y: u16,
@ -941,7 +941,7 @@ struct PanelE2eRect {
} }
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
struct PanelE2eRenderedRow { struct PanelE2eRenderedRow {
key: PanelE2eRowKey, key: PanelE2eRowKey,
title: String, title: String,
@ -951,12 +951,49 @@ struct PanelE2eRenderedRow {
} }
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
struct PanelE2eRowsRendered { struct PanelE2eRowsRendered {
selected: Option<PanelE2eRowKey>, selected: Option<PanelE2eRowKey>,
rows: Vec<PanelE2eRenderedRow>, rows: Vec<PanelE2eRenderedRow>,
} }
#[cfg(feature = "e2e-test")]
#[derive(Debug, Serialize)]
struct PanelE2eDashboardContentReady {
ticket_configured: bool,
selected: Option<PanelE2eRowKey>,
categories: PanelE2eDashboardCategories,
diagnostics: Vec<String>,
rows: Vec<PanelE2eRenderedRow>,
}
#[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<PanelE2eSourceTiming>,
ticket_rows: usize,
pod_rows: usize,
diagnostics: usize,
}
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey {
match key { match key {
@ -992,6 +1029,34 @@ fn panel_e2e_rect(rect: Rect) -> PanelE2eRect {
} }
} }
#[cfg(feature = "e2e-test")]
fn panel_e2e_dashboard_categories(rows: &[PanelE2eRenderedRow]) -> PanelE2eDashboardCategories {
PanelE2eDashboardCategories {
ticket_rows: rows.iter().filter(|row| row.key.kind == "ticket").count(),
ready_ticket_rows: rows
.iter()
.filter(|row| row.key.kind == "ticket" && row.status.as_deref() == Some("ready"))
.count(),
planning_ticket_rows: rows
.iter()
.filter(|row| row.key.kind == "ticket" && row.status.as_deref() == Some("planning"))
.count(),
pod_rows: rows.iter().filter(|row| row.key.kind == "pod").count(),
actionable_rows: rows.iter().filter(|row| row.action.is_some()).count(),
}
}
#[cfg(feature = "e2e-test")]
fn panel_e2e_dashboard_content_is_ready(
ticket_configured: bool,
categories: &PanelE2eDashboardCategories,
) -> bool {
ticket_configured
&& categories.ready_ticket_rows > 0
&& categories.planning_ticket_rows > 0
&& categories.pod_rows > 0
}
pub(crate) struct MultiPodApp { pub(crate) struct MultiPodApp {
pub(crate) list: PodList, pub(crate) list: PodList,
pub(crate) panel: WorkspacePanelViewModel, pub(crate) panel: WorkspacePanelViewModel,
@ -1010,6 +1075,8 @@ pub(crate) struct MultiPodApp {
last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>, last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>,
orchestrator_work_set: OrchestratorWorkSet, orchestrator_work_set: OrchestratorWorkSet,
orchestrator_queue_attention: Option<OrchestratorQueueAttentionFreshness>, orchestrator_queue_attention: Option<OrchestratorQueueAttentionFreshness>,
#[cfg(feature = "e2e-test")]
emitted_dashboard_content_ready: bool,
} }
impl MultiPodApp { impl MultiPodApp {
@ -1046,6 +1113,8 @@ impl MultiPodApp {
last_orchestrator_lifecycle_failure: None, last_orchestrator_lifecycle_failure: None,
orchestrator_work_set: OrchestratorWorkSet::default(), orchestrator_work_set: OrchestratorWorkSet::default(),
orchestrator_queue_attention: None, orchestrator_queue_attention: None,
#[cfg(feature = "e2e-test")]
emitted_dashboard_content_ready: false,
} }
} }
@ -1355,8 +1424,8 @@ impl MultiPodApp {
} }
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
fn emit_rows_rendered(&self) { fn emit_rows_rendered(&mut self) {
let rows = self let rows: Vec<_> = self
.row_hit_boxes .row_hit_boxes
.iter() .iter()
.map(|hit| { .map(|hit| {
@ -1386,14 +1455,35 @@ impl MultiPodApp {
} }
}) })
.collect(); .collect();
let selected = self.selected_row.as_ref().map(panel_e2e_row_key);
crate::e2e_observer::emit( crate::e2e_observer::emit(
"panel", "panel",
"rows_rendered", "rows_rendered",
PanelE2eRowsRendered { PanelE2eRowsRendered {
selected: self.selected_row.as_ref().map(panel_e2e_row_key), selected: selected.clone(),
rows, rows: rows.clone(),
}, },
); );
if !self.emitted_dashboard_content_ready {
let categories = panel_e2e_dashboard_categories(&rows);
if panel_e2e_dashboard_content_is_ready(
self.panel.header.ticket_configured,
&categories,
) {
crate::e2e_observer::emit(
"panel",
"dashboard_content_ready",
PanelE2eDashboardContentReady {
ticket_configured: self.panel.header.ticket_configured,
selected,
categories,
diagnostics: self.panel.header.diagnostics.clone(),
rows,
},
);
self.emitted_dashboard_content_ready = true;
}
}
} }
fn ensure_selection_visible(&mut self) { fn ensure_selection_visible(&mut self) {
@ -2286,12 +2376,35 @@ async fn load_multi_pod_snapshot(
lifecycle_mode: OrchestratorLifecycleMode, lifecycle_mode: OrchestratorLifecycleMode,
) -> Result<MultiPodSnapshot, MultiPodError> { ) -> Result<MultiPodSnapshot, MultiPodError> {
let workspace_root = current_workspace_root(); 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 companion_pod_name = workspace_companion_pod_name(&workspace_root);
let list_selected_name = selected_name let list_selected_name = selected_name
.clone() .clone()
.or_else(|| Some(companion_pod_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?; let mut list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
#[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming {
source: "pod_list.initial",
elapsed_ms: source_started.elapsed().as_millis(),
});
#[cfg(feature = "e2e-test")]
let source_started = Instant::now();
let companion_presence = load_exact_companion_pod_presence(&companion_pod_name).await?; 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() { let companion = match lifecycle_mode.clone() {
OrchestratorLifecycleMode::Ensure { runtime_command } => { OrchestratorLifecycleMode::Ensure { runtime_command } => {
ensure_workspace_companion( ensure_workspace_companion(
@ -2306,17 +2419,48 @@ async fn load_multi_pod_snapshot(
observe_workspace_companion(companion_pod_name, companion_presence) 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 { if companion.reload_pods {
#[cfg(feature = "e2e-test")]
let source_started = Instant::now();
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")]
source_timings.push(PanelE2eSourceTiming {
source: "pod_list.after_companion_reload",
elapsed_ms: source_started.elapsed().as_millis(),
});
} }
#[cfg(feature = "e2e-test")]
let source_started = Instant::now();
let config = ticket_config_availability(&workspace_root); let config = ticket_config_availability(&workspace_root);
#[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming {
source: "ticket_config",
elapsed_ms: source_started.elapsed().as_millis(),
});
let orchestrator_pod_name = workspace_orchestrator_pod_name(&workspace_root); let orchestrator_pod_name = workspace_orchestrator_pod_name(&workspace_root);
#[cfg(feature = "e2e-test")]
let source_started = Instant::now();
let orchestrator_presence = match &config { let orchestrator_presence = match &config {
TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None, TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None,
TicketConfigAvailability::Usable => { TicketConfigAvailability::Usable => {
Some(load_exact_pod_presence(&orchestrator_pod_name).await?) 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 { let orchestrator = match lifecycle_mode {
OrchestratorLifecycleMode::Ensure { runtime_command } => { OrchestratorLifecycleMode::Ensure { runtime_command } => {
ensure_workspace_orchestrator( ensure_workspace_orchestrator(
@ -2332,14 +2476,51 @@ async fn load_multi_pod_snapshot(
observe_workspace_orchestrator(config, orchestrator_pod_name, orchestrator_presence) 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 { if orchestrator.reload_pods {
#[cfg(feature = "e2e-test")]
let source_started = Instant::now();
list = load_pod_list(list_selected_name, MAX_ENTRIES).await?; list = load_pod_list(list_selected_name, MAX_ENTRIES).await?;
#[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming {
source: "pod_list.after_orchestrator_reload",
elapsed_ms: source_started.elapsed().as_millis(),
});
} }
#[cfg(feature = "e2e-test")]
let source_started = Instant::now();
let mut panel = build_workspace_panel(&workspace_root, &list); 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")]
source_timings.push(PanelE2eSourceTiming {
source: "workspace_panel.build",
elapsed_ms: source_started.elapsed().as_millis(),
});
#[cfg(feature = "e2e-test")]
crate::e2e_observer::emit(
"panel",
"dashboard_source_breakdown",
PanelE2eDashboardSourceBreakdown {
total_elapsed_ms: load_started.elapsed().as_millis(),
sources: source_timings,
ticket_rows: panel
.rows
.iter()
.filter(|row| row.is_ticket_action())
.count(),
pod_rows: list.entries.len(),
diagnostics: panel.header.diagnostics.len(),
},
);
Ok(MultiPodSnapshot { list, panel }) Ok(MultiPodSnapshot { list, panel })
} }

View File

@ -298,6 +298,104 @@ impl ExpectedPanelTicketRow {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpectedDashboardContent {
pub tickets: Vec<ExpectedPanelTicketRow>,
pub pod_names: Vec<String>,
}
impl ExpectedDashboardContent {
fn description(&self) -> String {
let tickets = self
.tickets
.iter()
.map(ExpectedPanelTicketRow::description)
.collect::<Vec<_>>()
.join(", ");
let pods = self.pod_names.join(", ");
format!("tickets=[{tickets}] pods=[{pods}]")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardContentSnapshot {
pub tickets: Vec<ExpectedPanelTicketRow>,
pub pod_names: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardContentReady {
pub ticket_configured: bool,
pub selected: Option<PanelRowKey>,
pub categories: DashboardContentCategories,
#[serde(default)]
pub diagnostics: Vec<String>,
pub rows: Vec<RenderedPanelRow>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardContentCategories {
pub ticket_rows: usize,
pub ready_ticket_rows: usize,
pub planning_ticket_rows: usize,
pub pod_rows: usize,
pub actionable_rows: usize,
}
impl DashboardContentReady {
pub fn rows_rendered(&self) -> RowsRendered {
RowsRendered {
selected: self.selected.clone(),
rows: self.rows.clone(),
}
}
pub fn snapshot_for_expected(
&self,
expected: &ExpectedDashboardContent,
) -> DashboardContentSnapshot {
DashboardContentSnapshot {
tickets: expected
.tickets
.iter()
.filter(|ticket| self.rows.iter().any(|row| ticket.matches(row)))
.cloned()
.collect(),
pod_names: expected
.pod_names
.iter()
.filter(|pod_name| {
self.rows
.iter()
.any(|row| row.key.kind == "pod" && row.key.id == pod_name.as_str())
})
.cloned()
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardSourceBreakdown {
pub total_elapsed_ms: u128,
pub sources: Vec<DashboardSourceTiming>,
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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RowsRendered { pub struct RowsRendered {
pub selected: Option<PanelRowKey>, pub selected: Option<PanelRowKey>,
@ -542,6 +640,52 @@ impl PanelHarness {
serde_json::from_value(event.data).map_err(HarnessError::from) 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<DashboardContentReady> {
let expected_snapshot = DashboardContentSnapshot {
tickets: expected.tickets.clone(),
pod_names: expected.pod_names.clone(),
};
let description = expected.description();
let event = self.wait_for(
format!("dashboard content ready ({description})"),
timeout,
|event| {
if event.event != "dashboard_content_ready" {
return false;
}
serde_json::from_value::<DashboardContentReady>(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<Option<DashboardSourceBreakdown>> {
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<DashboardSourceBreakdown> {
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( pub fn assert_fixture_ticket_row_not_rendered(
&mut self, &mut self,
expected: &ExpectedPanelTicketRow, expected: &ExpectedPanelTicketRow,
@ -1041,6 +1185,24 @@ impl FixtureWorkspace {
) )
} }
pub fn planning_fixture_ticket_row(&self) -> ExpectedPanelTicketRow {
ExpectedPanelTicketRow::new(
self.planning_ticket_id.clone(),
PLANNING_FIXTURE_TICKET_TITLE,
"planning",
)
}
pub fn expected_dashboard_content(&self) -> ExpectedDashboardContent {
ExpectedDashboardContent {
tickets: vec![
self.ready_fixture_ticket_row(),
self.planning_fixture_ticket_row(),
],
pod_names: vec!["workspace".to_string()],
}
}
pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig { pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig {
PanelHarnessConfig { PanelHarnessConfig {
binary, binary,

View File

@ -1,7 +1,7 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500); 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::{ use yoi_e2e::{
ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness,
@ -114,11 +114,11 @@ fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Res
} }
#[test] #[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 binary = yoi_binary()?;
let fixture = FixtureWorkspace::new(&binary)?; let fixture = FixtureWorkspace::new(&binary)?;
assert_fixture_paths_are_isolated(&fixture); 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 started = Instant::now();
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
@ -137,23 +137,47 @@ fn panel_fixture_ticket_row_ready_has_startup_budget() -> yoi_e2e::Result<()> {
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );
let rows_ready_remaining = ROWS_READY_BUDGET let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET
.checked_sub(started.elapsed()) .checked_sub(started.elapsed())
.unwrap_or_else(|| Duration::from_millis(0)); .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!( assert!(
rows.has_fixture_ticket_row(&ready_ticket), content_ready.ticket_configured,
"rows-ready event must contain concrete ready fixture Ticket row; artifacts at {}", "dashboard content ready must include usable Ticket configuration; artifacts at {}",
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );
let rows_ready_elapsed = started.elapsed(); assert!(
content_ready.categories.ready_ticket_rows > 0
&& content_ready.categories.planning_ticket_rows > 0
&& content_ready.categories.pod_rows > 0,
"dashboard content ready must include ready Ticket, planning Ticket, and Pod categories; got {:?}; artifacts at {}",
content_ready.categories,
panel.artifacts().dir.display()
);
let content_ready_elapsed = started.elapsed();
eprintln!( 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() panel.artifacts().dir.display()
); );
assert!( assert!(
rows_ready_elapsed <= ROWS_READY_BUDGET, content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET,
"fixture rows ready took {rows_ready_elapsed:?}, budget {ROWS_READY_BUDGET:?}; artifacts at {}", "dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}",
panel.artifacts().dir.display()
);
let source_breakdown = panel.expect_dashboard_source_breakdown()?;
assert!(
source_breakdown.has_source("pod_list.initial")
&& source_breakdown.has_source("ticket_config")
&& source_breakdown.has_source("workspace_panel.build"),
"dashboard source breakdown should include pod, ticket, and panel-build sources; got {:?}; artifacts at {}",
source_breakdown,
panel.artifacts().dir.display()
);
eprintln!(
"panel dashboard source breakdown: {:?}; artifacts at {}",
source_breakdown,
panel.artifacts().dir.display() panel.artifacts().dir.display()
); );