diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index f69c61927..f87e62dab 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -1,6 +1,6 @@ use reqwest::Method; -use serde::Serialize; use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use server_api::{ BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, @@ -22,6 +22,21 @@ use crate::{BackendApiClient, BackendWorkspaceClientError}; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; +#[derive(Debug, Deserialize)] +struct BackendObjectiveListResponse { + workspace_id: String, + limit: usize, + items: Vec, + invalid_records: Vec, + record_authority: String, +} + +#[derive(Debug, Deserialize)] +struct BackendInvalidProjectRecord { + label: String, + reason: String, +} + /// Workspace-scoped Backend client for Ticket and Objective product state. /// /// Construction requires both the selected Backend URL and Workspace identity. @@ -177,7 +192,23 @@ impl BackendWorkspaceProductClient { &self, limit: usize, ) -> Result, BackendWorkspaceClientError> { - self.get_json(&format!("/objectives?limit={limit}")) + let response: BackendObjectiveListResponse = + self.get_json(&format!("/objectives?limit={limit}"))?; + Ok(ListResponse { + workspace_id: response.workspace_id, + limit: response.limit, + items: response.items, + source: response.record_authority, + diagnostics: response + .invalid_records + .into_iter() + .map(|invalid| server_api::Diagnostic { + code: "invalid_objective_record".to_string(), + severity: server_api::DiagnosticSeverity::Warning, + message: format!("{}: {}", invalid.label, invalid.reason), + }) + .collect(), + }) } pub fn show_objective(&self, id: &str) -> Result { @@ -794,7 +825,7 @@ mod tests { #[test] fn objective_list_uses_workspace_scoped_backend_route() { - let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#; + let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#; let (base_url, request, handle) = one_response_server("200 OK", body); let client = BackendWorkspaceProductClient::new_with_access_token( base_url, @@ -806,6 +837,13 @@ mod tests { let response = client.list_objectives(1_000).unwrap(); assert!(response.items.is_empty()); + assert_eq!(response.source, "sqlite"); + assert_eq!(response.diagnostics.len(), 1); + assert_eq!(response.diagnostics[0].code, "invalid_objective_record"); + assert_eq!( + response.diagnostics[0].message, + "broken.md: invalid frontmatter" + ); let request = request.recv().unwrap(); assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")); assert!(request.contains("authorization: Bearer test-backend-token\r\n")); diff --git a/crates/tui/src/backend_dashboard.rs b/crates/tui/src/backend_dashboard.rs index 7fcdc36cb..fc8a88ae5 100644 --- a/crates/tui/src/backend_dashboard.rs +++ b/crates/tui/src/backend_dashboard.rs @@ -2,7 +2,7 @@ use std::io; use std::time::Duration; use client::{BackendWorkspaceProductClient, ObjectiveSummary}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, @@ -79,8 +79,26 @@ async fn run_loop( app: &mut BackendDashboard, client: BackendWorkspaceProductClient, ) -> Result<(), Box> { + let mut first_frame = true; + let mut initial_snapshot_pending = false; loop { terminal.draw(|frame| draw(frame, app))?; + if first_frame { + first_frame = false; + #[cfg(feature = "e2e-test")] + { + crate::e2e_observer::emit( + "backend_dashboard", + "panel_ready", + serde_json::json!({ "workspace_id": app.workspace_id }), + ); + initial_snapshot_pending = true; + } + } else if initial_snapshot_pending { + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_content_ready"); + initial_snapshot_pending = false; + } if !event::poll(Duration::from_millis(100))? { continue; } @@ -91,21 +109,50 @@ async fn run_loop( continue; } match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Char('q') | KeyCode::Esc => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "backend_dashboard", + "quit_requested", + serde_json::json!({}), + ); + return Ok(()); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "backend_dashboard", + "quit_requested", + serde_json::json!({}), + ); + return Ok(()); + } KeyCode::Tab | KeyCode::BackTab => { app.focus = match app.focus { Focus::Tickets => Focus::Objectives, Focus::Objectives => Focus::Tickets, }; + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_selection_changed"); + } + KeyCode::Down | KeyCode::Char('j') => { + app.select_next(); + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_selection_changed"); + } + KeyCode::Up | KeyCode::Char('k') => { + app.select_previous(); + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_selection_changed"); } - KeyCode::Down | KeyCode::Char('j') => app.select_next(), - KeyCode::Up | KeyCode::Char('k') => app.select_previous(), KeyCode::Char('r') => match load(&client).await { Ok((tickets, objectives)) => { app.tickets = tickets; app.objectives = objectives; app.clamp_selection(); app.status = "Reloaded from Backend authority".to_string(); + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_reloaded"); } Err(error) => app.status = format!("Backend reload failed: {error}"), }, @@ -144,6 +191,51 @@ async fn run_loop( } } +#[cfg(feature = "e2e-test")] +fn emit_e2e_snapshot(app: &BackendDashboard, event: &'static str) { + let focus = match app.focus { + Focus::Tickets => "tickets", + Focus::Objectives => "objectives", + }; + let tickets = app + .tickets + .iter() + .map(|ticket| { + serde_json::json!({ + "id": ticket.id, + "resource_key": ticket.resource_key, + "title": ticket.title, + "workflow_state": ticket.workflow_state.as_str(), + }) + }) + .collect::>(); + let objectives = app + .objectives + .iter() + .map(|objective| { + serde_json::json!({ + "id": objective.id, + "resource_key": objective.resource_key, + "title": objective.title, + "state": objective.state, + }) + }) + .collect::>(); + crate::e2e_observer::emit( + "backend_dashboard", + event, + serde_json::json!({ + "workspace_id": app.workspace_id, + "focus": focus, + "selected_ticket_id": app.selected_ticket_id(), + "selected_objective_id": app.objectives.get(app.selected_objective).map(|objective| &objective.id), + "tickets": tickets, + "objectives": objectives, + "status": app.status, + }), + ); +} + impl BackendDashboard { fn select_next(&mut self) { match self.focus { diff --git a/crates/tui/src/e2e_observer.rs b/crates/tui/src/e2e_observer.rs index 77eceb609..91a81a8e2 100644 --- a/crates/tui/src/e2e_observer.rs +++ b/crates/tui/src/e2e_observer.rs @@ -2,12 +2,11 @@ use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; const EVENT_PATH_ENV: &str = "YOI_TUI_TEST_EVENTS"; -const HOLD_BACKGROUND_TASK_ENV: &str = "YOI_TUI_TEST_HOLD_BACKGROUND_TASK"; static EVENT_WRITER: OnceLock>> = OnceLock::new(); @@ -44,25 +43,6 @@ where } } -pub(crate) async fn hold_background_task_if_requested(task: &'static str) { - let requested = std::env::var(HOLD_BACKGROUND_TASK_ENV).unwrap_or_default(); - if !requested - .split(',') - .map(str::trim) - .any(|requested| requested == task) - { - return; - } - emit( - "panel", - "background_task_hold_started", - serde_json::json!({ "task": task }), - ); - loop { - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - fn open_event_writer() -> Option> { let path = std::env::var_os(EVENT_PATH_ENV).map(PathBuf::from)?; if let Some(parent) = path.parent() { diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index 731d97113..b97101201 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -140,25 +140,11 @@ impl FsRuntimeStore { })?; } let snapshot_exists = root.join(RUNTIME_FILE).is_file(); - let workers_dir = root.join(WORKERS_DIR); - if !snapshot_exists - && workers_dir.is_dir() - && fs::read_dir(&workers_dir) - .map_err(|source| RuntimeError::StoreIo { - operation: "inspect uninitialized runtime store", - path: workers_dir.clone(), - source, - })? - .next() - .is_some() - { - return Err(RuntimeError::StoreCorrupt { - operation: "open runtime store", - path: root, - message: "Worker records exist without a Runtime snapshot".to_string(), - }); + if root_existed && !snapshot_exists { + validate_fresh_runtime_store_layout(&root)?; } + let workers_dir = root.join(WORKERS_DIR); fs::create_dir_all(&workers_dir).map_err(|source| RuntimeError::StoreIo { operation: "create runtime store", path: workers_dir, @@ -362,6 +348,38 @@ impl FsRuntimeStore { } } +fn validate_fresh_runtime_store_layout(root: &Path) -> Result<(), RuntimeError> { + let entries = fs::read_dir(root).map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store", + path: root.to_path_buf(), + source, + })?; + for entry in entries { + let entry = entry.map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store", + path: root.to_path_buf(), + source, + })?; + let file_type = entry.file_type().map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store entry", + path: entry.path(), + source, + })?; + if entry.file_name() == "auth.toml" && file_type.is_file() { + continue; + } + return Err(RuntimeError::StoreCorrupt { + operation: "open runtime store", + path: root.to_path_buf(), + message: format!( + "unexpected persistence `{}` exists without a Runtime snapshot", + entry.file_name().to_string_lossy() + ), + }); + } + Ok(()) +} + fn acquire_runtime_store_owner_lock( root: &Path, ) -> Result, RuntimeError> { @@ -2173,6 +2191,41 @@ mod tests { ); } + #[test] + fn snapshotless_directory_rejects_non_auth_persistence() { + for unexpected in ["events.jsonl", "unknown.json"] { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("auth.toml"), b"fixture auth material").unwrap(); + fs::write(root.join(unexpected), b"must remain").unwrap(); + + let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err(); + + assert!(matches!( + error, + RuntimeError::StoreCorrupt { path, .. } if path == root + )); + assert_eq!(fs::read(root.join(unexpected)).unwrap(), b"must remain"); + assert!(!root.join(WORKERS_DIR).exists()); + } + } + + #[test] + fn orphan_archive_without_runtime_snapshot_is_rejected() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(root.join(ORPHANED_WORKERS_DIR).join("worker-a")).unwrap(); + + let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err(); + + assert!(matches!( + error, + RuntimeError::StoreCorrupt { path, .. } if path == root + )); + assert!(root.join(ORPHANED_WORKERS_DIR).join("worker-a").is_dir()); + } + #[test] fn worker_records_without_runtime_snapshot_are_rejected() { let parent = tempfile::tempdir().unwrap(); diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index c8e5ff568..6719adbaa 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -628,6 +628,7 @@ impl PanelHarness { let mut command = Command::new(&config.binary); command + .current_dir(&config.workspace) .args(&config.command_args) .env_clear() .env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl) @@ -1338,8 +1339,8 @@ pub struct FixtureCleanupReport { pub report_path: PathBuf, } -pub const READY_FIXTURE_TICKET_TITLE: &str = "Ready E2E Ticket"; -pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket"; +pub const READY_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket A"; +pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket B"; #[derive(Debug)] pub struct FixtureWorkspace { @@ -1354,6 +1355,7 @@ pub struct FixtureWorkspace { pub artifacts_dir: PathBuf, pub ready_ticket_id: String, pub planning_ticket_id: String, + backend_environment: Option, } impl FixtureWorkspace { @@ -1365,7 +1367,7 @@ impl FixtureWorkspace { Self::new_inner(binary, false) } - fn new_inner(binary: &Path, setup_dashboard: bool) -> Result { + fn new_inner(_binary: &Path, setup_dashboard: bool) -> Result { let allocation = allocate_fixture("tui")?; let root = allocation.root; let artifacts_dir = allocation.artifacts_dir; @@ -1384,163 +1386,45 @@ impl FixtureWorkspace { artifacts_dir, ready_ticket_id: String::new(), planning_ticket_id: String::new(), + backend_environment: None, }; fixture.write_fixture_metadata("created", None)?; if setup_dashboard { - let worker_metadata_root = active_worker_metadata_root(&fixture.home); - write_blocking_worker_metadata(&worker_metadata_root, "workspace")?; - write_blocking_worker_metadata(&worker_metadata_root, "workspace-orchestrator")?; - run_yoi( - binary, - &fixture.workspace, - &fixture.home, - &fixture.xdg_data_home, - &fixture.xdg_state_home, - &fixture.xdg_config_home, - &fixture.xdg_runtime_dir, - &fixture.artifacts_dir, - &["ticket", "init"], - )?; - let first = create_ticket( - binary, - &fixture.workspace, - &fixture.home, - &fixture.xdg_data_home, - &fixture.xdg_state_home, - &fixture.xdg_config_home, - &fixture.xdg_runtime_dir, - &fixture.artifacts_dir, - READY_FIXTURE_TICKET_TITLE, - )?; - run_yoi( - binary, - &fixture.workspace, - &fixture.home, - &fixture.xdg_data_home, - &fixture.xdg_state_home, - &fixture.xdg_config_home, - &fixture.xdg_runtime_dir, - &fixture.artifacts_dir, - &["ticket", "state", &first, "ready"], - )?; - let second = create_ticket( - binary, - &fixture.workspace, - &fixture.home, - &fixture.xdg_data_home, - &fixture.xdg_state_home, - &fixture.xdg_config_home, - &fixture.xdg_runtime_dir, - &fixture.artifacts_dir, - PLANNING_FIXTURE_TICKET_TITLE, - )?; - fixture.ready_ticket_id = first; - fixture.planning_ticket_id = second; - fixture.setup_orchestration_overlay(binary)?; + let mut backend = WorkerE2eEnvironment::new()?; + backend.start_backend()?; + backend.install_backend_token(&fixture.xdg_config_home)?; + fixture.ready_ticket_id = backend.create_planning_ticket(READY_FIXTURE_TICKET_TITLE)?; + fixture.planning_ticket_id = + backend.create_planning_ticket(PLANNING_FIXTURE_TICKET_TITLE)?; + backend.capture_panel_seed()?; + fixture.backend_environment = Some(backend); } fixture.write_fixture_metadata("ready", None)?; Ok(fixture) } - pub fn ready_fixture_ticket_row(&self) -> ExpectedPanelTicketRow { - ExpectedPanelTicketRow::new( - self.ready_ticket_id.clone(), - READY_FIXTURE_TICKET_TITLE, - "ready", - ) - .with_action("Queue") - .with_local_state("ready") - } - - pub fn ready_overlay_ticket_row(&self) -> ExpectedPanelTicketRow { - ExpectedPanelTicketRow::new( - self.ready_ticket_id.clone(), - READY_FIXTURE_TICKET_TITLE, - "ready→prog", - ) - .with_action("Wait") - .with_disabled_reason("orchestration worktree overlay shows Ticket state inprogress") - .with_local_state("ready") - .with_overlay_state("inprogress") - } - - pub fn planning_fixture_ticket_row(&self) -> ExpectedPanelTicketRow { - ExpectedPanelTicketRow::new( - self.planning_ticket_id.clone(), - PLANNING_FIXTURE_TICKET_TITLE, - "planning", - ) - .with_action("Clarify") - .with_disabled_reason("Ticket is still in planning") - .with_local_state("planning") - } - - pub fn expected_dashboard_content(&self) -> ExpectedDashboardContent { - ExpectedDashboardContent { - tickets: vec![ - self.ready_overlay_ticket_row(), - self.planning_fixture_ticket_row(), - ], - worker_names: vec!["workspace".to_string()], - companion_status: "spawned".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 backend_target(&self) -> Option<(&str, &str)> { + self.backend_environment + .as_ref() + .and_then(|backend| backend.backend_target().ok()) } pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig { + let command_args = if let Some(backend) = self.backend_environment.as_ref() { + let (server_url, workspace_id) = backend + .backend_target() + .expect("started Backend panel fixture has a target"); + vec![ + "--backend".to_string(), + server_url.to_string(), + "--workspace-id".to_string(), + workspace_id.to_string(), + "panel".to_string(), + ] + } else { + Vec::new() + }; PanelHarnessConfig { binary, workspace: self.workspace.clone(), @@ -1553,11 +1437,7 @@ impl FixtureWorkspace { terminal_size: (100, 32), hold_background_task: None, rewind_fixture: false, - command_args: vec![ - "panel".to_string(), - "--workspace".to_string(), - self.workspace.display().to_string(), - ], + command_args, artifacts_dir: self.artifacts_dir.clone(), } } @@ -1590,18 +1470,34 @@ impl FixtureWorkspace { } fn cleanup_inner(&mut self, strict: bool) -> Result { + let mut cleanup_errors = Vec::new(); + let token_path = self.xdg_config_home.join("yoi/backend-tokens.json"); + match fs::remove_file(&token_path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => cleanup_errors.push(format!( + "remove Backend client credential {}: {error}", + token_path.display() + )), + } + if let Some(backend) = self.backend_environment.take() + && let Err(error) = backend.cleanup() + { + cleanup_errors.push(format!("cleanup Backend panel environment: {error}")); + } + let snapshot_dir = self.artifacts_dir.join("fixture-snapshot"); if snapshot_dir.exists() { fs::remove_dir_all(&snapshot_dir)?; } copy_dir_recursive(&self.root, &snapshot_dir)?; - let mut cleanup_error = None; - if let Some(temp_root) = self.temp_root.take() { - if let Err(err) = temp_root.close() { - cleanup_error = Some(err.to_string()); - } + if let Some(temp_root) = self.temp_root.take() + && let Err(err) = temp_root.close() + { + cleanup_errors.push(err.to_string()); } + let cleanup_error = (!cleanup_errors.is_empty()).then(|| cleanup_errors.join("; ")); let fixture_root_exists_after = self.root.exists(); let cleanup_success = cleanup_error.is_none() && !fixture_root_exists_after; let report = FixtureCleanupReport { @@ -1648,7 +1544,7 @@ impl FixtureWorkspace { "ready": { "id": &self.ready_ticket_id, "title": READY_FIXTURE_TICKET_TITLE, - "state": "ready" + "state": "planning" }, "planning": { "id": &self.planning_ticket_id, @@ -1661,8 +1557,10 @@ impl FixtureWorkspace { "host_runtime_inherited": false, "host_xdg_runtime_dir_present": std::env::var_os("XDG_RUNTIME_DIR").is_some(), "tested_yoi_runtime_source": "fixture XDG_RUNTIME_DIR", - "tested_yoi_worker_allocation": self.xdg_runtime_dir.join("yoi").join("workers.json"), - "fixture_worker_metadata_root": active_worker_metadata_root(&self.home) + "backend_target": self.backend_target().map(|(base_url, workspace_id)| serde_json::json!({ + "base_url": base_url, + "workspace_id": workspace_id, + })), }, "tested_yoi_env_policy": tested_yoi_env_policy_overview(), "cleanup": cleanup, @@ -1873,145 +1771,6 @@ fn open_pty(size: (u16, u16)) -> Result<(File, File)> { Ok((master, slave)) } -fn create_ticket( - binary: &Path, - workspace: &Path, - home: &Path, - data: &Path, - state: &Path, - config: &Path, - runtime: &Path, - artifacts_dir: &Path, - title: &str, -) -> Result { - let output = run_yoi_capture( - binary, - workspace, - home, - data, - state, - config, - runtime, - artifacts_dir, - &["ticket", "create", "--title", title], - )?; - output - .split_whitespace() - .find(|part| part.len() >= 13 && part.chars().all(|ch| ch.is_ascii_alphanumeric())) - .map(ToOwned::to_owned) - .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, - home: &Path, - data: &Path, - state: &Path, - config: &Path, - runtime: &Path, - artifacts_dir: &Path, - args: &[&str], -) -> Result<()> { - let output = run_yoi_capture( - binary, - workspace, - home, - data, - state, - config, - runtime, - artifacts_dir, - args, - )?; - drop(output); - Ok(()) -} - -fn run_yoi_capture( - binary: &Path, - workspace: &Path, - home: &Path, - data: &Path, - state: &Path, - config: &Path, - runtime: &Path, - artifacts_dir: &Path, - args: &[&str], -) -> Result { - let env_policy = fixture_setup_env_policy(); - append_fixture_command_artifact(artifacts_dir, workspace, binary, args, &env_policy)?; - - let mut command = Command::new(binary); - command - .args(args) - .current_dir(workspace) - .env_clear() - .env("HOME", home) - .env("XDG_DATA_HOME", data) - .env("XDG_STATE_HOME", state) - .env("XDG_CONFIG_HOME", config) - .env("XDG_RUNTIME_DIR", runtime) - .env("YOI_POD_RUNTIME_COMMAND", binary); - - let output = command.output()?; - if !output.status.success() { - return Err(HarnessError::CommandFailed { - program: binary.to_path_buf(), - args: args.iter().map(|arg| (*arg).to_owned()).collect(), - status: output.status, - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }); - } - let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); - text.push_str(&String::from_utf8_lossy(&output.stderr)); - Ok(text) -} - -fn append_fixture_command_artifact( - artifacts_dir: &Path, - workspace: &Path, - binary: &Path, - args: &[&str], - env_policy: &EnvPolicy, -) -> Result<()> { - fs::create_dir_all(artifacts_dir)?; - let mut file = OpenOptions::new() - .append(true) - .create(true) - .open(artifacts_dir.join("fixture-commands.jsonl"))?; - serde_json::to_writer( - &mut file, - &serde_json::json!({ - "ts_ms": now_ms(), - "binary": binary, - "workspace": workspace, - "args": args, - "tested_yoi_env_policy": env_policy, - }), - )?; - writeln!(file)?; - Ok(()) -} - fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> { if !source.exists() { return Ok(()); @@ -2037,17 +1796,6 @@ fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> { Ok(()) } -fn active_worker_metadata_root(home: &Path) -> PathBuf { - home.join(".yoi").join("workers") -} - -fn write_blocking_worker_metadata(worker_metadata_root: &Path, worker_name: &str) -> Result<()> { - let dir = worker_metadata_root.join(worker_name); - fs::create_dir_all(&dir)?; - fs::write(dir.join("metadata.json"), b"not valid metadata for e2e\n")?; - Ok(()) -} - fn output_has_enabled_mouse_capture(output: &[u8]) -> bool { mouse_mode_enabled(output, b"\x1b[?1000h", b"\x1b[?1000l") && mouse_mode_enabled(output, b"\x1b[?1006h", b"\x1b[?1006l") diff --git a/tests/e2e/src/worker_environment.rs b/tests/e2e/src/worker_environment.rs index fb92b1fbb..ec60d7078 100644 --- a/tests/e2e/src/worker_environment.rs +++ b/tests/e2e/src/worker_environment.rs @@ -263,6 +263,8 @@ impl WorkerE2eEnvironment { serde_json::to_vec_pretty(&binaries)?, )?; let client = Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_secs(1)) .timeout(Duration::from_secs(3)) .build() @@ -295,6 +297,11 @@ impl WorkerE2eEnvironment { } pub fn start(&mut self) -> Result<()> { + self.start_backend()?; + self.start_runtime() + } + + pub fn start_backend(&mut self) -> Result<()> { assert_absent_or_empty(&self.server_data_dir, "Server data directory")?; assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?; let server_binary = self.binaries.binary("yoi-server")?; @@ -369,7 +376,14 @@ impl WorkerE2eEnvironment { })?; let bundle_path = self.root.join("workspace-public-identity.json"); fs::write(&bundle_path, serde_json::to_vec_pretty(&public_bundle)?)?; + self.write_metadata("backend-running")?; + Ok(()) + } + fn start_runtime(&mut self) -> Result<()> { + let bundle_path = self.root.join("workspace-public-identity.json"); + let server_url = self.required_server_url()?.to_string(); + let workspace_id = self.required_workspace_id()?.to_string(); let runtime_binary = self.binaries.binary("yoi-runtime")?; self.run_command( &runtime_binary, @@ -501,6 +515,91 @@ impl WorkerE2eEnvironment { Ok(()) } + pub fn install_backend_token(&self, config_home: &Path) -> Result { + let server_url = self.required_server_url()?; + let access_token = self.access_token.as_deref().ok_or_else(|| { + HarnessError::Protocol("Backend access token is not initialized".to_string()) + })?; + let token_path = config_home.join("yoi/backend-tokens.json"); + if let Some(parent) = token_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + &token_path, + serde_json::to_vec_pretty(&serde_json::json!({ + "tokens": { + server_url: { + "token_type": "Bearer", + "access_token": access_token, + } + } + }))?, + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&token_path, fs::Permissions::from_mode(0o600))?; + } + Ok(token_path) + } + + pub fn create_planning_ticket(&self, title: &str) -> Result { + let server_url = self.required_server_url()?; + let workspace_id = self.required_workspace_id()?; + let created = self.request_json( + self.client + .post(format!("{server_url}/api/w/{workspace_id}/tickets")) + .json(&serde_json::json!({ + "title": title, + "slug": null, + "kind": "task", + "priority": "P2", + "labels": [], + "body": "Backend-owned panel E2E fixture.", + "author": null, + "assignee": null, + "readiness": null, + "risk_flags": [], + "workflow_state": "planning", + "queued_by": null, + "queued_at": null, + "repository_key": null, + "ref_selector": null, + })), + "create Ticket", + )?; + json_string(&created, "/id") + } + + pub fn capture_panel_seed(&self) -> Result<()> { + let server_url = self.required_server_url()?; + let workspace_id = self.required_workspace_id()?; + let tickets = self.request_json( + self.client.get(format!( + "{server_url}/api/w/{workspace_id}/tickets/search?state=active" + )), + "list panel fixture Tickets", + )?; + let objectives = self.request_json( + self.client.get(format!( + "{server_url}/api/w/{workspace_id}/objectives?limit=1000" + )), + "list panel fixture Objectives", + )?; + fs::write( + self.artifacts_dir.join("panel-seed.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "tickets": tickets, + "objectives": objectives, + }))?, + )?; + Ok(()) + } + + pub fn backend_target(&self) -> Result<(&str, &str)> { + Ok((self.required_server_url()?, self.required_workspace_id()?)) + } + pub fn server_port(&self) -> Result { url_port(self.required_server_url()?) } @@ -652,7 +751,12 @@ impl WorkerE2eEnvironment { "binary": binary, "args": args, "status": output.status.code(), - "stdout": if redact_stdout { "" } else { "captured-not-persisted" }, + "stdout": if redact_stdout { + "".to_string() + } else { + bounded_text(&output.stdout, MAX_COMMAND_OUTPUT_BYTES) + }, + "stderr": bounded_text(&output.stderr, MAX_COMMAND_OUTPUT_BYTES), "env_clear": true, }), )?; diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index bed46db28..058e27827 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -1,663 +1,198 @@ +use std::fs; use std::time::{Duration, Instant}; +use serde_json::Value; +use yoi_e2e::{FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, yoi_binary}; + 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, -}; - -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: id.to_string(), - }, - 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_worker_row(name: &str) -> RenderedPanelRow { - RenderedPanelRow { - key: PanelRowKey { - kind: "worker".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 { - worker_name: "workspace".to_string(), - status: "unavailable".to_string(), - }), - orchestrator: Some(DashboardOrchestratorState { - worker_name: "workspace-orchestrator".to_string(), - status: "unavailable".to_string(), - detail: Some("fixture blocks host Worker launch".to_string()), - }), - diagnostics: vec![], - }, - rows, - }, - categories: DashboardContentCategories { - ticket_rows: 2, - ready_ticket_rows: 1, - planning_ticket_rows: 1, - worker_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: "worker".to_string(), - id: "0000000000000".to_string(), - }, - title: "Ready E2E Ticket".to_string(), - status: Some("ready".to_string()), - 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, - width: 10, - height: 1, - }, - }; - - assert!(!expected.matches(&wrong_title)); - assert!(!expected.matches(&wrong_kind)); - let rows = RowsRendered { - selected: None, - rows: vec![wrong_title, wrong_kind], - }; - 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()], - worker_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_worker_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_worker_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<()> { +fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_ctrl_c() +-> yoi_e2e::Result<()> { let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; - assert_fixture_paths_are_isolated(&fixture); - let ready_ticket = fixture.ready_fixture_ticket_row(); - - let started = Instant::now(); - let mut panel = - PanelHarness::spawn(fixture.panel_config_holding_background_task(binary, "reload"))?; - let remaining = FIRST_VISIBLE_RENDER_BUDGET - .checked_sub(started.elapsed()) - .unwrap_or_else(|| Duration::from_millis(0)); - panel.wait_for_first_visible_frame(remaining)?; - let first_visible_elapsed = started.elapsed(); - eprintln!( - "panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", - panel.artifacts().dir.display() - ); - assert!( - first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, - "first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", - panel.artifacts().dir.display() - ); - - let events = panel.events()?; - let ready_index = events - .iter() - .position(|event| event.event == "panel_ready") - .expect("panel_ready event should be present"); - assert!( - events[..ready_index] - .iter() - .all(|event| event.event != "background_task_started"), - "initial frame must be emitted before reload/background work starts; artifacts at {}", - panel.artifacts().dir.display() - ); - - panel.expect_background_task_pending("reload")?; - let events = panel.events()?; - let reload_started_index = events - .iter() - .position(|event| { - event.event == "background_task_started" - && event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload") - }) - .expect("held reload should start after first visible frame"); - assert!( - ready_index < reload_started_index, - "first visible frame and reload ordering should remain separate; artifacts at {}", - panel.artifacts().dir.display() - ); - panel.assert_fixture_ticket_row_not_rendered(&ready_ticket, Duration::from_millis(150))?; - - panel.press(KeyPress::CtrlC)?; - let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; - assert!(status.success(), "panel should exit cleanly with Ctrl+C"); - drop(panel); - assert_fixture_cleanup(fixture.cleanup()?); - Ok(()) -} - -#[test] -fn panel_dashboard_content_ready_has_startup_budget() -> yoi_e2e::Result<()> { - let binary = yoi_binary()?; - let fixture = FixtureWorkspace::new(&binary)?; - assert_fixture_paths_are_isolated(&fixture); - let expected_content = fixture.expected_dashboard_content(); + assert_backend_fixture_is_isolated(&fixture); + let ready_ticket_id = fixture.ready_ticket_id.clone(); + let planning_ticket_id = fixture.planning_ticket_id.clone(); + let (_, workspace_id) = fixture + .backend_target() + .expect("panel fixture should own a Backend target"); + let workspace_id = workspace_id.to_string(); let started = Instant::now(); let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; - let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET - .checked_sub(started.elapsed()) - .unwrap_or_else(|| Duration::from_millis(0)); - panel.wait_for_first_visible_frame(first_visible_remaining)?; + panel.wait_for_first_visible_frame(FIRST_VISIBLE_RENDER_BUDGET)?; let first_visible_elapsed = started.elapsed(); - eprintln!( - "panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}", - panel.artifacts().dir.display() - ); assert!( first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET, - "first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", + "first visible Backend panel frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}", panel.artifacts().dir.display() ); - let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET - .checked_sub(started.elapsed()) - .unwrap_or_else(|| Duration::from_millis(0)); - let content_ready = - panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?; - assert!( - 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() - ); + let ready = panel.wait_for( + "Backend dashboard content ready with fixture Tickets", + DASHBOARD_CONTENT_READY_BUDGET, + |event| { + event.event == "backend_dashboard_content_ready" + && event.data.get("workspace_id").and_then(Value::as_str) + == Some(workspace_id.as_str()) + && has_backend_ticket( + &event.data, + &ready_ticket_id, + "Planning E2E Ticket A", + "planning", + ) + && has_backend_ticket( + &event.data, + &planning_ticket_id, + "Planning E2E Ticket B", + "planning", + ) + }, + )?; 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() + ready.data.get("focus").and_then(Value::as_str), + Some("tickets") ); assert!( - content_ready.categories.ready_ticket_rows > 0 - && content_ready.categories.planning_ticket_rows > 0 - && content_ready.categories.worker_rows > 0, - "dashboard content ready must include ready Ticket, planning Ticket, and Worker categories; got {:?}; artifacts at {}", - content_ready.categories, - panel.artifacts().dir.display() - ); - let content_ready_elapsed = started.elapsed(); - eprintln!( - "panel dashboard content ready: {content_ready_elapsed:?} (budget {DASHBOARD_CONTENT_READY_BUDGET:?}; first frame {first_visible_elapsed:?}); artifacts at {}", - panel.artifacts().dir.display() - ); - assert!( - content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET, - "dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}", + started.elapsed() <= DASHBOARD_CONTENT_READY_BUDGET, + "Backend dashboard content missed startup budget; artifacts at {}", panel.artifacts().dir.display() ); - let source_breakdown = panel.expect_dashboard_source_breakdown()?; - assert!( - source_breakdown.has_source("pod_metadata_status_probe.initial") - && source_breakdown.has_source("ticket_config_probe") - && source_breakdown.has_source("local_claim_scan") - && source_breakdown.has_source("ticket_scan_parse") - && source_breakdown.has_source("orchestration_overlay_validation_read_git") - && source_breakdown.has_source("workspace_panel.build.total"), - "dashboard source breakdown should include worker metadata/status, ticket scan/parse, overlay validation/read/git, local claim scan, and panel-build sources; got {:?}; artifacts at {}", - source_breakdown, - panel.artifacts().dir.display() - ); - eprintln!( - "panel dashboard source breakdown: {:?}; artifacts at {}", - source_breakdown, - panel.artifacts().dir.display() - ); + panel.press(KeyPress::Text("j".to_string()))?; + panel.wait_for( + "Backend dashboard Ticket selection change", + Duration::from_secs(2), + |event| { + event.event == "backend_dashboard_selection_changed" + && event.data.get("selected_ticket_id").and_then(Value::as_str) + == Some(planning_ticket_id.as_str()) + }, + )?; + + panel.press(KeyPress::Text("r".to_string()))?; + panel.wait_for( + "Backend dashboard reload", + Duration::from_secs(2), + |event| { + event.event == "backend_dashboard_reloaded" + && has_backend_ticket( + &event.data, + &ready_ticket_id, + "Planning E2E Ticket A", + "planning", + ) + && has_backend_ticket( + &event.data, + &planning_ticket_id, + "Planning E2E Ticket B", + "planning", + ) + }, + )?; panel.press(KeyPress::CtrlC)?; let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; - assert!(status.success(), "panel should exit cleanly with Ctrl+C"); + assert!( + status.success(), + "Backend panel should exit cleanly with Ctrl+C" + ); + assert!( + panel + .events()? + .iter() + .any(|event| event.event == "quit_requested") + ); drop(panel); - assert_fixture_cleanup(fixture.cleanup()?); + + let report = fixture.cleanup()?; + assert_fixture_cleanup(&report); + assert!( + !report + .snapshot_dir + .join("config/yoi/backend-tokens.json") + .exists(), + "Backend credential must be removed before fixture snapshot" + ); Ok(()) } #[test] -fn panel_dashboard_content_ready_from_shell_enter_path() -> yoi_e2e::Result<()> { +fn backend_panel_shell_enter_path_uses_the_same_backend_authority() -> yoi_e2e::Result<()> { let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; - assert_fixture_paths_are_isolated(&fixture); - let expected_content = fixture.expected_dashboard_content(); + assert_backend_fixture_is_isolated(&fixture); + let ready_ticket_id = fixture.ready_ticket_id.clone(); let (mut panel, started) = PanelHarness::spawn_via_shell_enter(fixture.panel_config(binary))?; - let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET - .checked_sub(started.elapsed()) - .unwrap_or_else(|| Duration::from_millis(0)); - panel.wait_for_first_visible_frame(first_visible_remaining)?; - let first_visible_elapsed = started.elapsed(); - - let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET - .checked_sub(started.elapsed()) - .unwrap_or_else(|| Duration::from_millis(0)); - let content_ready = - panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?; - assert_eq!( - content_ready.snapshot_for_expected(&expected_content), - expected_content.snapshot(), - "shell-enter dashboard content ready must match expected Ticket/action/overlay/header snapshot; artifacts at {}", - panel.artifacts().dir.display() - ); - let content_ready_elapsed = started.elapsed(); - eprintln!( - "panel shell-enter dashboard content ready: {content_ready_elapsed:?} (budget {DASHBOARD_CONTENT_READY_BUDGET:?}; first frame {first_visible_elapsed:?}); artifacts at {}", - panel.artifacts().dir.display() - ); + panel.wait_for_first_visible_frame(FIRST_VISIBLE_RENDER_BUDGET)?; + panel.wait_for( + "shell-enter Backend dashboard content", + DASHBOARD_CONTENT_READY_BUDGET, + |event| { + event.event == "backend_dashboard_content_ready" + && has_backend_ticket( + &event.data, + &ready_ticket_id, + "Planning E2E Ticket A", + "planning", + ) + }, + )?; assert!( - content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET, - "shell-enter dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}", + started.elapsed() <= DASHBOARD_CONTENT_READY_BUDGET, + "shell-enter Backend dashboard content missed startup budget; artifacts at {}", panel.artifacts().dir.display() ); - let source_breakdown = panel.expect_dashboard_source_breakdown()?; - assert!( - source_breakdown.has_source("workspace_panel.build.total"), - "shell-enter dashboard source breakdown should include total panel build source; got {:?}; artifacts at {}", - source_breakdown, - panel.artifacts().dir.display() - ); - - panel.press(KeyPress::CtrlC)?; + panel.press(KeyPress::Esc)?; let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; - assert!(status.success(), "panel should exit cleanly with Ctrl+C"); + assert!( + status.success(), + "Backend panel should exit cleanly with Escape" + ); drop(panel); - assert_fixture_cleanup(fixture.cleanup()?); + let report = fixture.cleanup()?; + assert_fixture_cleanup(&report); Ok(()) } -#[test] -fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> { - let binary = yoi_binary()?; - let fixture = FixtureWorkspace::new(&binary)?; - assert_fixture_paths_are_isolated(&fixture); - let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; - - panel.expect_mouse_capture_enabled()?; - panel.assert_no_full_drag_mouse_capture()?; - let rows = panel.wait_for_rows(2)?; - assert_no_runtime_or_host_pod_leak( - &fixture, - &rows.rows, - panel.artifacts().dir.display().to_string().as_str(), - ); - let selected = rows.selected.clone(); - let target = rows - .rows - .iter() - .find(|row| Some(&row.key) != selected.as_ref()) - .cloned() - .expect("fixture should render a second selectable row"); - - let before_events = panel.events()?.len(); - panel.click(&target)?; - panel.expect_selection(&target.key)?; - - let events = panel.events()?; - assert!( - events[before_events..] - .iter() - .all(|event| event.event != "action_requested"), - "mouse selection must not dispatch panel actions; artifacts at {}", - panel.artifacts().dir.display() - ); - panel.assert_no_full_drag_mouse_capture()?; - - panel.press(KeyPress::CtrlC)?; - let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; - assert!(status.success(), "panel should exit cleanly with Ctrl+C"); - drop(panel); - assert_fixture_cleanup(fixture.cleanup()?); - Ok(()) +fn has_backend_ticket(data: &Value, id: &str, title: &str, workflow_state: &str) -> bool { + data.get("tickets") + .and_then(Value::as_array) + .is_some_and(|tickets| { + tickets.iter().any(|ticket| { + ticket.get("id").and_then(Value::as_str) == Some(id) + && ticket.get("title").and_then(Value::as_str) == Some(title) + && ticket.get("workflow_state").and_then(Value::as_str) == Some(workflow_state) + }) + }) } -#[test] -fn panel_mouse_wheel_moves_selection_without_full_drag_capture() -> yoi_e2e::Result<()> { - let binary = yoi_binary()?; - let fixture = FixtureWorkspace::new(&binary)?; - assert_fixture_paths_are_isolated(&fixture); - let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; - - panel.expect_mouse_capture_enabled()?; - panel.assert_no_full_drag_mouse_capture()?; - let rows = panel.wait_for_rows(2)?; - assert_no_runtime_or_host_pod_leak( - &fixture, - &rows.rows, - panel.artifacts().dir.display().to_string().as_str(), - ); - let selected = rows - .selected - .as_ref() - .expect("fixture should render an initially selected row") - .clone(); - let selected_index = rows - .rows - .iter() - .position(|row| row.key == selected) - .expect("selected row should be rendered"); - let target_index = (selected_index + 1).min(rows.rows.len() - 1); - assert_ne!( - selected_index, target_index, - "fixture should render a wheel-selectable next row" - ); - let source = rows.rows[selected_index].clone(); - let target = rows.rows[target_index].clone(); - - let before_events = panel.events()?.len(); - panel.wheel_down(&source)?; - panel.expect_selection(&target.key)?; - panel.assert_no_full_drag_mouse_capture()?; - - let events = panel.events()?; - assert!( - events[before_events..] - .iter() - .any(|event| event.event == "mouse_wheel"), - "wheel movement should be visible in e2e events; artifacts at {}", - panel.artifacts().dir.display() - ); - assert!( - events[before_events..] - .iter() - .all(|event| event.event != "action_requested"), - "wheel selection must not dispatch panel actions; artifacts at {}", - panel.artifacts().dir.display() - ); - - panel.press(KeyPress::CtrlC)?; - let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; - assert!(status.success(), "panel should exit cleanly with Ctrl+C"); - drop(panel); - assert_fixture_cleanup(fixture.cleanup()?); - Ok(()) -} - -#[test] -fn panel_ctrl_c_exits_promptly_after_background_barrier() -> yoi_e2e::Result<()> { - let binary = yoi_binary()?; - let fixture = FixtureWorkspace::new(&binary)?; - assert_fixture_paths_are_isolated(&fixture); - let mut panel = - PanelHarness::spawn(fixture.panel_config_holding_background_task(binary, "reload"))?; - - panel.wait_for("panel_ready", Duration::from_secs(5), |event| { - event.event == "panel_ready" - })?; - panel.expect_background_task_pending("reload")?; - - let started = std::time::Instant::now(); - panel.press(KeyPress::CtrlC)?; - let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?; - let elapsed = started.elapsed(); - - assert!(status.success(), "panel should exit cleanly with Ctrl+C"); - assert!( - elapsed <= PanelHarness::default_exit_wait(), - "quit latency {elapsed:?} exceeded threshold; artifacts at {}", - panel.artifacts().dir.display() - ); - let events = panel.events()?; - assert!( - events.iter().any(|event| event.event == "quit_requested"), - "quit_requested observability event missing; artifacts at {}", - panel.artifacts().dir.display() - ); - assert!( - events.iter().any(|event| { - event.event == "background_task_aborted" - && event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload") - }), - "pending reload task should be aborted before quit completes; artifacts at {}", - panel.artifacts().dir.display() - ); - drop(panel); - assert_fixture_cleanup(fixture.cleanup()?); - Ok(()) -} - -fn assert_fixture_paths_are_isolated(fixture: &FixtureWorkspace) { - assert!( - fixture.root.exists(), - "fixture temp root should exist during scenario" - ); +fn assert_backend_fixture_is_isolated(fixture: &FixtureWorkspace) { + assert!(fixture.root.exists()); assert!(fixture.workspace.starts_with(&fixture.root)); assert!(fixture.home.starts_with(&fixture.root)); assert!(fixture.xdg_data_home.starts_with(&fixture.root)); assert!(fixture.xdg_state_home.starts_with(&fixture.root)); assert!(fixture.xdg_config_home.starts_with(&fixture.root)); assert!(fixture.xdg_runtime_dir.starts_with(&fixture.root)); - assert!( - !fixture.artifacts_dir.starts_with(&fixture.root), - "persistent artifacts must live outside the temp root so cleanup can remove the fixture" - ); - if let Some(host_runtime) = std::env::var_os("XDG_RUNTIME_DIR") { - assert_ne!( - fixture.xdg_runtime_dir, - std::path::PathBuf::from(host_runtime), - "tested yoi must not reuse host XDG_RUNTIME_DIR" - ); - } + assert!(!fixture.workspace.join(".yoi").exists()); + assert!(fixture.backend_target().is_some()); } -fn assert_no_runtime_or_host_pod_leak( - fixture: &FixtureWorkspace, - rows: &[RenderedPanelRow], - artifacts: &str, -) { - let rendered = rows - .iter() - .map(|row| { - format!( - "{} {} {} {}", - row.key.kind, - row.key.id, - row.title, - row.status.as_deref().unwrap_or_default() - ) - }) - .collect::>() - .join("\n"); - for marker in [ - "workspace-orchestrator", - "yoi-orchestrator-orchestrator", - "host-runtime-leak", - ] { - assert!( - !rendered.contains(marker), - "host/fixture runtime Worker marker {marker:?} leaked into panel rows; artifacts at {artifacts}\n{rendered}" - ); - } - if let Some(host_runtime) = std::env::var_os("XDG_RUNTIME_DIR") { - let host_runtime = host_runtime.to_string_lossy(); - assert!( - !rendered.contains(host_runtime.as_ref()), - "host XDG_RUNTIME_DIR leaked into panel rows; artifacts at {artifacts}\n{rendered}" - ); - } - assert!( - rendered.contains("E2E Ticket"), - "panel should be observing fixture-local Ticket data; artifacts at {artifacts}\n{rendered}" - ); - assert!(fixture.xdg_runtime_dir.starts_with(&fixture.root)); -} - -fn assert_fixture_cleanup(report: FixtureCleanupReport) { +fn assert_fixture_cleanup(report: &FixtureCleanupReport) { assert!( report.cleanup_success, "fixture cleanup failed; report at {}: {:?}", report.report_path.display(), report.cleanup_error ); - assert!( - !report.fixture_root.exists(), - "fixture temp root should be removed after scenario: {}", - report.fixture_root.display() - ); - assert!( - report.report_path.exists(), - "cleanup artifact should persist" - ); - assert!( - report.snapshot_dir.exists(), - "fixture snapshot should persist under target/e2e-artifacts before temp cleanup" - ); + assert!(!report.fixture_root.exists()); + assert!(report.report_path.exists()); + assert!(report.snapshot_dir.exists()); + let snapshot = fs::read_to_string(report.snapshot_dir.join("../fixture.json")).unwrap(); + assert!(!snapshot.contains("yoi_api_")); }