Merge remote-tracking branch 'origin/develop' into work/T-452-remove-repository-local-yoi
This commit is contained in:
Generated
+2
@@ -6274,8 +6274,10 @@ name = "yoi-e2e"
|
|||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde::Serialize;
|
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use server_api::{
|
use server_api::{
|
||||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||||
@@ -22,6 +22,21 @@ use crate::{BackendApiClient, BackendWorkspaceClientError};
|
|||||||
|
|
||||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BackendObjectiveListResponse {
|
||||||
|
workspace_id: String,
|
||||||
|
limit: usize,
|
||||||
|
items: Vec<ObjectiveSummary>,
|
||||||
|
invalid_records: Vec<BackendInvalidProjectRecord>,
|
||||||
|
record_authority: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BackendInvalidProjectRecord {
|
||||||
|
label: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
||||||
///
|
///
|
||||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
/// Construction requires both the selected Backend URL and Workspace identity.
|
||||||
@@ -177,7 +192,23 @@ impl BackendWorkspaceProductClient {
|
|||||||
&self,
|
&self,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<ListResponse<ObjectiveSummary>, BackendWorkspaceClientError> {
|
) -> Result<ListResponse<ObjectiveSummary>, 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<ObjectiveDetail, BackendWorkspaceClientError> {
|
pub fn show_objective(&self, id: &str) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||||
@@ -794,7 +825,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
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 (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
base_url,
|
base_url,
|
||||||
@@ -806,6 +837,13 @@ mod tests {
|
|||||||
let response = client.list_objectives(1_000).unwrap();
|
let response = client.list_objectives(1_000).unwrap();
|
||||||
|
|
||||||
assert!(response.items.is_empty());
|
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();
|
let request = request.recv().unwrap();
|
||||||
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
||||||
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::io;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::{BackendWorkspaceProductClient, ObjectiveSummary};
|
use client::{BackendWorkspaceProductClient, ObjectiveSummary};
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame, Terminal,
|
Frame, Terminal,
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
@@ -79,8 +79,26 @@ async fn run_loop(
|
|||||||
app: &mut BackendDashboard,
|
app: &mut BackendDashboard,
|
||||||
client: BackendWorkspaceProductClient,
|
client: BackendWorkspaceProductClient,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut first_frame = true;
|
||||||
|
let mut initial_snapshot_pending = false;
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| draw(frame, app))?;
|
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))? {
|
if !event::poll(Duration::from_millis(100))? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -91,21 +109,50 @@ async fn run_loop(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match key.code {
|
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 => {
|
KeyCode::Tab | KeyCode::BackTab => {
|
||||||
app.focus = match app.focus {
|
app.focus = match app.focus {
|
||||||
Focus::Tickets => Focus::Objectives,
|
Focus::Tickets => Focus::Objectives,
|
||||||
Focus::Objectives => Focus::Tickets,
|
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 {
|
KeyCode::Char('r') => match load(&client).await {
|
||||||
Ok((tickets, objectives)) => {
|
Ok((tickets, objectives)) => {
|
||||||
app.tickets = tickets;
|
app.tickets = tickets;
|
||||||
app.objectives = objectives;
|
app.objectives = objectives;
|
||||||
app.clamp_selection();
|
app.clamp_selection();
|
||||||
app.status = "Reloaded from Backend authority".to_string();
|
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}"),
|
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::<Vec<_>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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 {
|
impl BackendDashboard {
|
||||||
fn select_next(&mut self) {
|
fn select_next(&mut self) {
|
||||||
match self.focus {
|
match self.focus {
|
||||||
|
|||||||
@@ -623,6 +623,16 @@ fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<Termina
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
pub(crate) async fn run_e2e_rewind(worker_name: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut terminal = enter_fullscreen()?;
|
||||||
|
let result = run_e2e_rewind_fixture(&mut terminal, worker_name).await;
|
||||||
|
let leave_result = leave_fullscreen(&mut terminal);
|
||||||
|
result?;
|
||||||
|
leave_result?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
async fn run_e2e_rewind_fixture(
|
async fn run_e2e_rewind_fixture(
|
||||||
terminal: &mut ConsoleTerminal,
|
terminal: &mut ConsoleTerminal,
|
||||||
@@ -637,6 +647,8 @@ async fn run_e2e_rewind_fixture(
|
|||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
},
|
},
|
||||||
state: WorkerStatus::Idle.into(),
|
state: WorkerStatus::Idle.into(),
|
||||||
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
|
internal_workers: Vec::new(),
|
||||||
greeting: Greeting {
|
greeting: Greeting {
|
||||||
worker_name: worker_name.clone(),
|
worker_name: worker_name.clone(),
|
||||||
cwd: workspace_root.display().to_string(),
|
cwd: workspace_root.display().to_string(),
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ use std::fs::{File, OpenOptions};
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
const EVENT_PATH_ENV: &str = "YOI_TUI_TEST_EVENTS";
|
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<Option<Mutex<File>>> = OnceLock::new();
|
static EVENT_WRITER: OnceLock<Option<Mutex<File>>> = 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<Mutex<File>> {
|
fn open_event_writer() -> Option<Mutex<File>> {
|
||||||
let path = std::env::var_os(EVENT_PATH_ENV).map(PathBuf::from)?;
|
let path = std::env::var_os(EVENT_PATH_ENV).map(PathBuf::from)?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
|
|||||||
+47
-26
@@ -137,7 +137,51 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
|||||||
}
|
}
|
||||||
let mut terminal_mode = TerminalModeGuard::new();
|
let mut terminal_mode = TerminalModeGuard::new();
|
||||||
|
|
||||||
let result = match mode {
|
#[cfg(feature = "e2e-test")]
|
||||||
|
let result = if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
|
||||||
|
let worker_name = match &mode {
|
||||||
|
LaunchMode::Spawn { worker_name, .. } => worker_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "e2e-rewind".to_string()),
|
||||||
|
_ => "e2e-rewind".to_string(),
|
||||||
|
};
|
||||||
|
console::run_e2e_rewind(worker_name).await
|
||||||
|
} else {
|
||||||
|
launch_mode(target.as_ref(), mode, &workspace_root).await
|
||||||
|
};
|
||||||
|
#[cfg(not(feature = "e2e-test"))]
|
||||||
|
let result = launch_mode(target.as_ref(), mode, &workspace_root).await;
|
||||||
|
|
||||||
|
// Always restore the terminal first so any pending eprintln below
|
||||||
|
// shows up cleanly in scrollback rather than inside an active
|
||||||
|
// alternate-screen buffer.
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({}));
|
||||||
|
let _ = terminal_mode.restore();
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({}));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" }));
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("yoi: {e}");
|
||||||
|
#[cfg(feature = "e2e-test")]
|
||||||
|
e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" }));
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn launch_mode(
|
||||||
|
target: &dyn Target,
|
||||||
|
mode: LaunchMode,
|
||||||
|
workspace_root: &std::path::Path,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match mode {
|
||||||
LaunchMode::Spawn {
|
LaunchMode::Spawn {
|
||||||
worker_name,
|
worker_name,
|
||||||
profile,
|
profile,
|
||||||
@@ -145,7 +189,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
|||||||
Ok(Some(selection)) => match target.spawn_worker() {
|
Ok(Some(selection)) => match target.spawn_worker() {
|
||||||
Ok(spawn) => {
|
Ok(spawn) => {
|
||||||
console::run_standalone(
|
console::run_standalone(
|
||||||
workspace_root.clone(),
|
workspace_root.to_path_buf(),
|
||||||
spawn.state_dir,
|
spawn.state_dir,
|
||||||
Some(selection.worker_name),
|
Some(selection.worker_name),
|
||||||
Some(selection.profile),
|
Some(selection.profile),
|
||||||
@@ -158,7 +202,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
|||||||
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
||||||
},
|
},
|
||||||
LaunchMode::StandaloneResume { include_all } => {
|
LaunchMode::StandaloneResume { include_all } => {
|
||||||
match standalone_picker::pick(target.as_ref(), include_all) {
|
match standalone_picker::pick(target, include_all) {
|
||||||
Ok(Some(intent)) => console::run_standalone_restore(intent).await,
|
Ok(Some(intent)) => console::run_standalone_restore(intent).await,
|
||||||
Ok(None) => Ok(()),
|
Ok(None) => Ok(()),
|
||||||
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
||||||
@@ -195,28 +239,5 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
|||||||
}
|
}
|
||||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
// Always restore the terminal first so any pending eprintln below
|
|
||||||
// shows up cleanly in scrollback rather than inside an active
|
|
||||||
// alternate-screen buffer.
|
|
||||||
#[cfg(feature = "e2e-test")]
|
|
||||||
e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({}));
|
|
||||||
let _ = terminal_mode.restore();
|
|
||||||
#[cfg(feature = "e2e-test")]
|
|
||||||
e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
#[cfg(feature = "e2e-test")]
|
|
||||||
e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" }));
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("yoi: {e}");
|
|
||||||
#[cfg(feature = "e2e-test")]
|
|
||||||
e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" }));
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
|
|||||||
ring.workspace = true
|
ring.workspace = true
|
||||||
tar.workspace = true
|
tar.workspace = true
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] }
|
tokio = { workspace = true, features = ["net", "process", "rt", "signal", "sync", "time"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ impl FsRuntimeStore {
|
|||||||
})?;
|
})?;
|
||||||
let owner_lock = acquire_runtime_store_owner_lock(&root)?;
|
let owner_lock = acquire_runtime_store_owner_lock(&root)?;
|
||||||
recover_runtime_store_migration(&root)?;
|
recover_runtime_store_migration(&root)?;
|
||||||
let existed = root.exists();
|
let root_existed = root.exists();
|
||||||
if existed && !root.is_dir() {
|
if root_existed && !root.is_dir() {
|
||||||
return Err(RuntimeError::StoreCorrupt {
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
operation: "open runtime store",
|
operation: "open runtime store",
|
||||||
path: root,
|
path: root,
|
||||||
@@ -132,17 +132,22 @@ impl FsRuntimeStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if !existed {
|
if !root_existed {
|
||||||
fs::create_dir_all(&root).map_err(|source| RuntimeError::StoreIo {
|
fs::create_dir_all(&root).map_err(|source| RuntimeError::StoreIo {
|
||||||
operation: "create runtime store root",
|
operation: "create runtime store root",
|
||||||
path: root.clone(),
|
path: root.clone(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
let snapshot_exists = root.join(RUNTIME_FILE).is_file();
|
||||||
|
if root_existed && !snapshot_exists {
|
||||||
|
validate_fresh_runtime_store_layout(&root)?;
|
||||||
|
}
|
||||||
|
|
||||||
fs::create_dir_all(root.join(WORKERS_DIR)).map_err(|source| RuntimeError::StoreIo {
|
let workers_dir = root.join(WORKERS_DIR);
|
||||||
|
fs::create_dir_all(&workers_dir).map_err(|source| RuntimeError::StoreIo {
|
||||||
operation: "create runtime store",
|
operation: "create runtime store",
|
||||||
path: root.join(WORKERS_DIR),
|
path: workers_dir,
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
let legacy_events = root.join("events.jsonl");
|
let legacy_events = root.join("events.jsonl");
|
||||||
@@ -158,14 +163,14 @@ impl FsRuntimeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if existed {
|
if snapshot_exists {
|
||||||
migrate_runtime_store(&root, runtime_id)?;
|
migrate_runtime_store(&root, runtime_id)?;
|
||||||
}
|
}
|
||||||
let store = Self {
|
let store = Self {
|
||||||
root,
|
root,
|
||||||
_owner_lock: Some(owner_lock),
|
_owner_lock: Some(owner_lock),
|
||||||
};
|
};
|
||||||
let state = if existed {
|
let state = if snapshot_exists {
|
||||||
Some(store.load_runtime_state()?)
|
Some(store.load_runtime_state()?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -343,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(
|
fn acquire_runtime_store_owner_lock(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
) -> Result<Arc<RuntimeStoreOwnerLock>, RuntimeError> {
|
) -> Result<Arc<RuntimeStoreOwnerLock>, RuntimeError> {
|
||||||
@@ -2137,6 +2174,72 @@ mod tests {
|
|||||||
document["schema_version"].as_u64().unwrap()
|
document["schema_version"].as_u64().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_initialized_directory_without_snapshot_is_a_fresh_store() {
|
||||||
|
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();
|
||||||
|
|
||||||
|
let opened = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap();
|
||||||
|
|
||||||
|
assert!(opened.state.is_none());
|
||||||
|
assert!(root.join(WORKERS_DIR).is_dir());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(root.join("auth.toml")).unwrap(),
|
||||||
|
b"fixture auth material"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
let root = parent.path().join("runtime");
|
||||||
|
fs::create_dir_all(root.join(WORKERS_DIR).join("orphan")).unwrap();
|
||||||
|
|
||||||
|
let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
RuntimeError::StoreCorrupt { path, .. } if path == root
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_store_owner_lock_child_probe() {
|
fn runtime_store_owner_lock_child_probe() {
|
||||||
let Some(root) = std::env::var_os("YOI_TEST_RUNTIME_STORE_LOCK_ROOT") else {
|
let Some(root) = std::env::var_os("YOI_TEST_RUNTIME_STORE_LOCK_ROOT") else {
|
||||||
|
|||||||
@@ -139,6 +139,22 @@ pub enum RuntimeHttpStoreSelection {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn serve_runtime_http_with_shutdown<F>(
|
||||||
|
runtime: Runtime,
|
||||||
|
listener: TcpListener,
|
||||||
|
local_token: Option<String>,
|
||||||
|
shutdown: F,
|
||||||
|
) -> Result<(), RuntimeHttpServerError>
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = ()> + Send + 'static,
|
||||||
|
{
|
||||||
|
let local_token = local_token.ok_or(RuntimeHttpServerError::AuthRequired)?;
|
||||||
|
axum::serve(listener, runtime_http_router(runtime, local_token))
|
||||||
|
.with_graceful_shutdown(shutdown)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve an existing Runtime on a pre-bound listener.
|
/// Serve an existing Runtime on a pre-bound listener.
|
||||||
pub async fn serve_runtime_http(
|
pub async fn serve_runtime_http(
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
@@ -150,6 +166,25 @@ pub async fn serve_runtime_http(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn serve_runtime_http_with_workspace_auth_shutdown<F>(
|
||||||
|
runtime: Runtime,
|
||||||
|
listener: TcpListener,
|
||||||
|
local_token: Option<String>,
|
||||||
|
workspace_auth: WorkspaceRuntimeHttpAuth,
|
||||||
|
shutdown: F,
|
||||||
|
) -> Result<(), RuntimeHttpServerError>
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = ()> + Send + 'static,
|
||||||
|
{
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth)),
|
||||||
|
)
|
||||||
|
.with_graceful_shutdown(shutdown)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn serve_runtime_http_with_workspace_auth(
|
pub async fn serve_runtime_http_with_workspace_auth(
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
|
|||||||
@@ -75,6 +75,23 @@ fn init_serve_tracing() {
|
|||||||
.try_init();
|
.try_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let mut terminate =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("install SIGTERM handler");
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => {},
|
||||||
|
_ = terminate.recv() => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn run() -> Result<(), ProcessError> {
|
fn run() -> Result<(), ProcessError> {
|
||||||
let args = env::args().skip(1).collect::<Vec<_>>();
|
let args = env::args().skip(1).collect::<Vec<_>>();
|
||||||
if matches!(args.first().map(String::as_str), Some("migrate")) {
|
if matches!(args.first().map(String::as_str), Some("migrate")) {
|
||||||
@@ -104,11 +121,12 @@ fn run() -> Result<(), ProcessError> {
|
|||||||
"yoi-runtime listening on {local_addr}; intended client is a trusted backend/proxy, not a browser"
|
"yoi-runtime listening on {local_addr}; intended client is a trusted backend/proxy, not a browser"
|
||||||
);
|
);
|
||||||
let server = if let Some(workspace_auth) = workspace_http_auth {
|
let server = if let Some(workspace_auth) = workspace_http_auth {
|
||||||
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
|
worker_runtime::http_server::serve_runtime_http_with_workspace_auth_shutdown(
|
||||||
worker_runtime,
|
worker_runtime,
|
||||||
listener,
|
listener,
|
||||||
config.http.local_token,
|
config.http.local_token,
|
||||||
workspace_auth,
|
workspace_auth,
|
||||||
|
shutdown_signal(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -117,10 +135,11 @@ fn run() -> Result<(), ProcessError> {
|
|||||||
"Runtime HTTP server requires Workspace issuer auth or --local-token".to_owned(),
|
"Runtime HTTP server requires Workspace issuer auth or --local-token".to_owned(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
worker_runtime::http_server::serve_runtime_http(
|
worker_runtime::http_server::serve_runtime_http_with_shutdown(
|
||||||
worker_runtime,
|
worker_runtime,
|
||||||
listener,
|
listener,
|
||||||
Some(local_token),
|
Some(local_token),
|
||||||
|
shutdown_signal(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ thiserror.workspace = true
|
|||||||
ticket.workspace = true
|
ticket.workspace = true
|
||||||
memory.workspace = true
|
memory.workspace = true
|
||||||
merge-request.workspace = true
|
merge-request.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegist
|
|||||||
pub use server::{
|
pub use server::{
|
||||||
AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router,
|
AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router,
|
||||||
build_workspace_server_router, serve, serve_workspace_catalog,
|
build_workspace_server_router, serve, serve_workspace_catalog,
|
||||||
|
serve_workspace_catalog_with_shutdown,
|
||||||
};
|
};
|
||||||
pub use server_api::{
|
pub use server_api::{
|
||||||
GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary,
|
GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary,
|
||||||
|
|||||||
@@ -9,18 +9,19 @@ use yoi_workspace_server::hosts::{
|
|||||||
EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin,
|
EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin,
|
||||||
};
|
};
|
||||||
use yoi_workspace_server::store::{
|
use yoi_workspace_server::store::{
|
||||||
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
|
AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord,
|
||||||
WorkspaceRuntimeBindingState,
|
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState,
|
||||||
};
|
};
|
||||||
use yoi_workspace_server::{
|
use yoi_workspace_server::{
|
||||||
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
||||||
WorkspaceRecord, serve_workspace_catalog,
|
WorkspaceRecord, serve_workspace_catalog_with_shutdown,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
Serve(ServeOptions),
|
Serve(ServeOptions),
|
||||||
Migrate(MigrateOptions),
|
Migrate(MigrateOptions),
|
||||||
|
BootstrapAuth(BootstrapAuthOptions),
|
||||||
Skills(SkillsCommand),
|
Skills(SkillsCommand),
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,13 @@ struct MigrateOptions {
|
|||||||
help: bool,
|
help: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct BootstrapAuthOptions {
|
||||||
|
handle: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct SkillWorkspaceOptions {
|
struct SkillWorkspaceOptions {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -77,6 +85,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
match parse_command(&args)? {
|
match parse_command(&args)? {
|
||||||
Command::Serve(options) => run_serve(options).await,
|
Command::Serve(options) => run_serve(options).await,
|
||||||
Command::Migrate(options) => run_migrate(options),
|
Command::Migrate(options) => run_migrate(options),
|
||||||
|
Command::BootstrapAuth(options) => run_bootstrap_auth(options),
|
||||||
Command::Skills(command) => run_skills(command),
|
Command::Skills(command) => run_skills(command),
|
||||||
Command::Help => Ok(()),
|
Command::Help => Ok(()),
|
||||||
}
|
}
|
||||||
@@ -90,6 +99,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
|
|
||||||
match command.as_str() {
|
match command.as_str() {
|
||||||
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
|
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
|
||||||
|
"bootstrap-auth" => parse_bootstrap_auth_options(rest).map(Command::BootstrapAuth),
|
||||||
"skills" => parse_skills_command(rest),
|
"skills" => parse_skills_command(rest),
|
||||||
"serve" => {
|
"serve" => {
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||||
@@ -103,7 +113,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
Ok(Command::Help)
|
Ok(Command::Help)
|
||||||
}
|
}
|
||||||
other => Err(CliError(format!(
|
other => Err(CliError(format!(
|
||||||
"unknown command `{other}`; expected `migrate`, `skills`, or `serve`"
|
"unknown command `{other}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +224,131 @@ fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let database_path = ServerConfig::default_server_database_path();
|
||||||
|
if let Some(parent) = database_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let store = SqliteWorkspaceStore::open(&database_path)?;
|
||||||
|
if !store.list_workspaces()?.is_empty() {
|
||||||
|
return Err(Box::new(CliError(
|
||||||
|
"bootstrap-auth is available only before the first Workspace is created".to_string(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = yoi_workspace_server::auth::normalize_handle(&options.handle)?;
|
||||||
|
let display_name = options
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(&handle)
|
||||||
|
.to_string();
|
||||||
|
let now = yoi_workspace_server::auth::now_rfc3339();
|
||||||
|
let user = match store.any_user()? {
|
||||||
|
Some(user) if user.handle == handle => user,
|
||||||
|
Some(_) => {
|
||||||
|
return Err(Box::new(CliError(
|
||||||
|
"bootstrap-auth refuses to replace an existing bootstrap user".to_string(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let account_id = yoi_workspace_server::auth::new_id("acct-user");
|
||||||
|
store.upsert_account(&AccountRecord {
|
||||||
|
account_id: account_id.clone(),
|
||||||
|
kind: "user".to_string(),
|
||||||
|
handle: handle.clone(),
|
||||||
|
display_name: display_name.clone(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now.clone(),
|
||||||
|
})?;
|
||||||
|
let user = UserRecord {
|
||||||
|
user_id: yoi_workspace_server::auth::new_id("user"),
|
||||||
|
account_id,
|
||||||
|
handle,
|
||||||
|
display_name,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now.clone(),
|
||||||
|
};
|
||||||
|
store.upsert_user(&user)?;
|
||||||
|
user
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let access_token = yoi_workspace_server::auth::mint_secret("yoi_api");
|
||||||
|
store.create_api_token(&ApiTokenRecord {
|
||||||
|
token_id: yoi_workspace_server::auth::new_id("api-token"),
|
||||||
|
token_hash: yoi_workspace_server::auth::token_hash(&access_token),
|
||||||
|
user_id: user.user_id.clone(),
|
||||||
|
label: options
|
||||||
|
.label
|
||||||
|
.unwrap_or_else(|| "initial host setup".to_string()),
|
||||||
|
created_at: now,
|
||||||
|
expires_at: None,
|
||||||
|
revoked_at: None,
|
||||||
|
last_used_at: None,
|
||||||
|
})?;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string(&serde_json::json!({
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": {
|
||||||
|
"user_id": user.user_id,
|
||||||
|
"account_id": user.account_id,
|
||||||
|
"handle": user.handle,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
}
|
||||||
|
}))?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bootstrap_auth_options(args: &[String]) -> Result<BootstrapAuthOptions, CliError> {
|
||||||
|
let mut handle = None;
|
||||||
|
let mut display_name = None;
|
||||||
|
let mut label = None;
|
||||||
|
let mut index = 0;
|
||||||
|
while index < args.len() {
|
||||||
|
let arg = &args[index];
|
||||||
|
let (field, target) = match arg.as_str() {
|
||||||
|
"--handle" => ("--handle", &mut handle),
|
||||||
|
"--display-name" => ("--display-name", &mut display_name),
|
||||||
|
"--label" => ("--label", &mut label),
|
||||||
|
_ if arg.starts_with("--handle=") => {
|
||||||
|
handle = Some(value_after_equals(arg, "--handle")?.to_string());
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--display-name=") => {
|
||||||
|
display_name = Some(value_after_equals(arg, "--display-name")?.to_string());
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--label=") => {
|
||||||
|
label = Some(value_after_equals(arg, "--label")?.to_string());
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
other => return Err(CliError(format!("unknown bootstrap-auth option `{other}`"))),
|
||||||
|
};
|
||||||
|
index += 1;
|
||||||
|
let value = args
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| CliError(format!("{field} requires a value")))?;
|
||||||
|
if target.replace(value.clone()).is_some() {
|
||||||
|
return Err(CliError(format!("{field} must be provided only once")));
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
let handle = handle.ok_or_else(|| CliError("bootstrap-auth requires --handle".to_string()))?;
|
||||||
|
Ok(BootstrapAuthOptions {
|
||||||
|
handle,
|
||||||
|
display_name,
|
||||||
|
label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn init_serve_tracing() {
|
fn init_serve_tracing() {
|
||||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||||
@@ -226,6 +361,23 @@ fn init_serve_tracing() {
|
|||||||
.try_init();
|
.try_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let mut terminate =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("install SIGTERM handler");
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => {},
|
||||||
|
_ = terminate.recv() => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
init_serve_tracing();
|
init_serve_tracing();
|
||||||
let database_path = ServerConfig::default_server_database_path();
|
let database_path = ServerConfig::default_server_database_path();
|
||||||
@@ -284,7 +436,8 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
database_path.display(),
|
database_path.display(),
|
||||||
local_addr
|
local_addr
|
||||||
);
|
);
|
||||||
serve_workspace_catalog(resolved.server, store, listener).await?;
|
serve_workspace_catalog_with_shutdown(resolved.server, store, listener, shutdown_signal())
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +643,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
|||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
"yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,6 +680,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_bootstrap_auth_requires_handle_and_accepts_host_setup_fields() {
|
||||||
|
let command = parse_command(&[
|
||||||
|
"bootstrap-auth".to_string(),
|
||||||
|
"--handle=e2e-owner".to_string(),
|
||||||
|
"--display-name".to_string(),
|
||||||
|
"E2E Owner".to_string(),
|
||||||
|
"--label".to_string(),
|
||||||
|
"e2e setup".to_string(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let Command::BootstrapAuth(options) = command else {
|
||||||
|
panic!("expected bootstrap-auth command");
|
||||||
|
};
|
||||||
|
assert_eq!(options.handle, "e2e-owner");
|
||||||
|
assert_eq!(options.display_name.as_deref(), Some("E2E Owner"));
|
||||||
|
assert_eq!(options.label.as_deref(), Some("e2e setup"));
|
||||||
|
|
||||||
|
let error = parse_bootstrap_auth_options(&[]).unwrap_err();
|
||||||
|
assert_eq!(error.to_string(), "bootstrap-auth requires --handle");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_migrate_uses_the_shared_schema_path() {
|
fn parse_migrate_uses_the_shared_schema_path() {
|
||||||
let command = parse_command(&[
|
let command = parse_command(&[
|
||||||
@@ -662,7 +837,9 @@ mod tests {
|
|||||||
let error = parse_command(&[command.to_owned()]).unwrap_err();
|
let error = parse_command(&[command.to_owned()]).unwrap_err();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
format!("unknown command `{command}`; expected `migrate`, `skills`, or `serve`")
|
format!(
|
||||||
|
"unknown command `{command}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1995,6 +1995,10 @@ struct WorkspaceListQuery {
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn server_health() -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({ "status": "ok" }))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_server_workspaces(
|
async fn list_server_workspaces(
|
||||||
State(api): State<WorkspaceServerApi>,
|
State(api): State<WorkspaceServerApi>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -2663,6 +2667,7 @@ pub async fn build_workspace_server_router(
|
|||||||
api.preload().await?;
|
api.preload().await?;
|
||||||
api.recover_workspace_deletions().await?;
|
api.recover_workspace_deletions().await?;
|
||||||
let catalog = Router::new()
|
let catalog = Router::new()
|
||||||
|
.route("/health", get(server_health))
|
||||||
.route(
|
.route(
|
||||||
"/api/workspaces",
|
"/api/workspaces",
|
||||||
get(list_server_workspaces).post(create_server_workspace),
|
get(list_server_workspaces).post(create_server_workspace),
|
||||||
@@ -4565,6 +4570,22 @@ struct ApiFailureLogEvent<'a> {
|
|||||||
diagnostics: Option<&'a [RuntimeDiagnostic]>,
|
diagnostics: Option<&'a [RuntimeDiagnostic]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn serve_workspace_catalog_with_shutdown<F>(
|
||||||
|
template: ServerConfig,
|
||||||
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
|
listener: TcpListener,
|
||||||
|
shutdown: F,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = ()> + Send + 'static,
|
||||||
|
{
|
||||||
|
let router = build_workspace_server_router(template, store).await?;
|
||||||
|
axum::serve(listener, router)
|
||||||
|
.with_graceful_shutdown(shutdown)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn serve_workspace_catalog(
|
pub async fn serve_workspace_catalog(
|
||||||
template: ServerConfig,
|
template: ServerConfig,
|
||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ e2e = []
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
@@ -25,3 +27,8 @@ required-features = ["e2e"]
|
|||||||
name = "rewind"
|
name = "rewind"
|
||||||
path = "tests/rewind.rs"
|
path = "tests/rewind.rs"
|
||||||
required-features = ["e2e"]
|
required-features = ["e2e"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "worker_environment"
|
||||||
|
path = "tests/worker_environment.rs"
|
||||||
|
required-features = ["e2e"]
|
||||||
|
|||||||
+136
-349
@@ -628,6 +628,7 @@ impl PanelHarness {
|
|||||||
|
|
||||||
let mut command = Command::new(&config.binary);
|
let mut command = Command::new(&config.binary);
|
||||||
command
|
command
|
||||||
|
.current_dir(&config.workspace)
|
||||||
.args(&config.command_args)
|
.args(&config.command_args)
|
||||||
.env_clear()
|
.env_clear()
|
||||||
.env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl)
|
.env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl)
|
||||||
@@ -1267,6 +1268,65 @@ impl Drop for PanelHarness {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod worker_environment;
|
||||||
|
|
||||||
|
pub use worker_environment::{
|
||||||
|
WorkerBinaryProviderInfo, WorkerE2eEnvironment, WorkerEnvironmentCleanupReport,
|
||||||
|
worker_binary_provider_info,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct FixtureAllocation {
|
||||||
|
temp_root: TempDir,
|
||||||
|
root: PathBuf,
|
||||||
|
home: PathBuf,
|
||||||
|
xdg_data_home: PathBuf,
|
||||||
|
xdg_state_home: PathBuf,
|
||||||
|
xdg_config_home: PathBuf,
|
||||||
|
xdg_runtime_dir: PathBuf,
|
||||||
|
artifacts_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allocate_fixture(kind: &str) -> Result<FixtureAllocation> {
|
||||||
|
let workspace_root = workspace_root()?;
|
||||||
|
let target_dir = workspace_root.join("target");
|
||||||
|
let temp_parent = target_dir.join("e2e-tmp");
|
||||||
|
let artifact_parent = target_dir.join("e2e-artifacts");
|
||||||
|
fs::create_dir_all(&temp_parent)?;
|
||||||
|
fs::create_dir_all(&artifact_parent)?;
|
||||||
|
let fixture_id = format!(
|
||||||
|
"{kind}-{}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
now_ms(),
|
||||||
|
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||||
|
);
|
||||||
|
let temp_root = tempfile::Builder::new()
|
||||||
|
.prefix(&format!("yoi-e2e-{fixture_id}-"))
|
||||||
|
.tempdir_in(&temp_parent)?;
|
||||||
|
let root = temp_root.path().to_path_buf();
|
||||||
|
let allocation = FixtureAllocation {
|
||||||
|
temp_root,
|
||||||
|
home: root.join("home"),
|
||||||
|
xdg_data_home: root.join("data"),
|
||||||
|
xdg_state_home: root.join("state"),
|
||||||
|
xdg_config_home: root.join("config"),
|
||||||
|
xdg_runtime_dir: root.join("run"),
|
||||||
|
artifacts_dir: artifact_parent.join(fixture_id),
|
||||||
|
root,
|
||||||
|
};
|
||||||
|
for dir in [
|
||||||
|
&allocation.home,
|
||||||
|
&allocation.xdg_data_home,
|
||||||
|
&allocation.xdg_state_home,
|
||||||
|
&allocation.xdg_config_home,
|
||||||
|
&allocation.xdg_runtime_dir,
|
||||||
|
&allocation.artifacts_dir,
|
||||||
|
] {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
Ok(allocation)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct FixtureCleanupReport {
|
pub struct FixtureCleanupReport {
|
||||||
pub fixture_root: PathBuf,
|
pub fixture_root: PathBuf,
|
||||||
@@ -1279,8 +1339,8 @@ pub struct FixtureCleanupReport {
|
|||||||
pub report_path: PathBuf,
|
pub report_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const READY_FIXTURE_TICKET_TITLE: &str = "Ready E2E Ticket";
|
pub const READY_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket A";
|
||||||
pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket";
|
pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket B";
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct FixtureWorkspace {
|
pub struct FixtureWorkspace {
|
||||||
@@ -1295,213 +1355,76 @@ pub struct FixtureWorkspace {
|
|||||||
pub artifacts_dir: PathBuf,
|
pub artifacts_dir: PathBuf,
|
||||||
pub ready_ticket_id: String,
|
pub ready_ticket_id: String,
|
||||||
pub planning_ticket_id: String,
|
pub planning_ticket_id: String,
|
||||||
|
backend_environment: Option<WorkerE2eEnvironment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FixtureWorkspace {
|
impl FixtureWorkspace {
|
||||||
pub fn new(binary: &Path) -> Result<Self> {
|
pub fn new(binary: &Path) -> Result<Self> {
|
||||||
let workspace_root = workspace_root()?;
|
Self::new_inner(binary, true)
|
||||||
let target_dir = workspace_root.join("target");
|
}
|
||||||
let temp_parent = target_dir.join("e2e-tmp");
|
|
||||||
let artifact_parent = target_dir.join("e2e-artifacts");
|
|
||||||
fs::create_dir_all(&temp_parent)?;
|
|
||||||
fs::create_dir_all(&artifact_parent)?;
|
|
||||||
|
|
||||||
let fixture_id = format!(
|
pub fn new_rewind(binary: &Path) -> Result<Self> {
|
||||||
"{}-{}-{}",
|
Self::new_inner(binary, false)
|
||||||
std::process::id(),
|
}
|
||||||
now_ms(),
|
|
||||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
fn new_inner(_binary: &Path, setup_dashboard: bool) -> Result<Self> {
|
||||||
);
|
let allocation = allocate_fixture("tui")?;
|
||||||
let temp_root = tempfile::Builder::new()
|
let root = allocation.root;
|
||||||
.prefix(&format!("yoi-e2e-{fixture_id}-"))
|
let artifacts_dir = allocation.artifacts_dir;
|
||||||
.tempdir_in(&temp_parent)?;
|
|
||||||
let root = temp_root.path().to_path_buf();
|
|
||||||
let artifacts_dir = artifact_parent.join(fixture_id);
|
|
||||||
let workspace = root.join("workspace");
|
let workspace = root.join("workspace");
|
||||||
let home = root.join("home");
|
fs::create_dir_all(&workspace)?;
|
||||||
let xdg_data_home = root.join("data");
|
|
||||||
let xdg_state_home = root.join("state");
|
|
||||||
let xdg_config_home = root.join("config");
|
|
||||||
let xdg_runtime_dir = root.join("runtime");
|
|
||||||
for dir in [
|
|
||||||
&workspace,
|
|
||||||
&home,
|
|
||||||
&xdg_data_home,
|
|
||||||
&xdg_state_home,
|
|
||||||
&xdg_config_home,
|
|
||||||
&xdg_runtime_dir,
|
|
||||||
&artifacts_dir,
|
|
||||||
] {
|
|
||||||
fs::create_dir_all(dir)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut fixture = Self {
|
let mut fixture = Self {
|
||||||
temp_root: Some(temp_root),
|
temp_root: Some(allocation.temp_root),
|
||||||
root,
|
root,
|
||||||
workspace,
|
workspace,
|
||||||
home,
|
home: allocation.home,
|
||||||
xdg_data_home,
|
xdg_data_home: allocation.xdg_data_home,
|
||||||
xdg_state_home,
|
xdg_state_home: allocation.xdg_state_home,
|
||||||
xdg_config_home,
|
xdg_config_home: allocation.xdg_config_home,
|
||||||
xdg_runtime_dir,
|
xdg_runtime_dir: allocation.xdg_runtime_dir,
|
||||||
artifacts_dir,
|
artifacts_dir,
|
||||||
ready_ticket_id: String::new(),
|
ready_ticket_id: String::new(),
|
||||||
planning_ticket_id: String::new(),
|
planning_ticket_id: String::new(),
|
||||||
|
backend_environment: None,
|
||||||
};
|
};
|
||||||
fixture.write_fixture_metadata("created", None)?;
|
fixture.write_fixture_metadata("created", None)?;
|
||||||
|
|
||||||
let worker_metadata_root = active_worker_metadata_root(&fixture.home);
|
if setup_dashboard {
|
||||||
write_blocking_worker_metadata(&worker_metadata_root, "workspace")?;
|
let mut backend = WorkerE2eEnvironment::new()?;
|
||||||
write_blocking_worker_metadata(&worker_metadata_root, "workspace-orchestrator")?;
|
backend.start_backend()?;
|
||||||
run_yoi(
|
backend.install_backend_token(&fixture.xdg_config_home)?;
|
||||||
binary,
|
fixture.ready_ticket_id = backend.create_planning_ticket(READY_FIXTURE_TICKET_TITLE)?;
|
||||||
&fixture.workspace,
|
fixture.planning_ticket_id =
|
||||||
&fixture.home,
|
backend.create_planning_ticket(PLANNING_FIXTURE_TICKET_TITLE)?;
|
||||||
&fixture.xdg_data_home,
|
backend.capture_panel_seed()?;
|
||||||
&fixture.xdg_state_home,
|
fixture.backend_environment = Some(backend);
|
||||||
&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)?;
|
|
||||||
fixture.write_fixture_metadata("ready", None)?;
|
fixture.write_fixture_metadata("ready", None)?;
|
||||||
Ok(fixture)
|
Ok(fixture)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ready_fixture_ticket_row(&self) -> ExpectedPanelTicketRow {
|
pub fn backend_target(&self) -> Option<(&str, &str)> {
|
||||||
ExpectedPanelTicketRow::new(
|
self.backend_environment
|
||||||
self.ready_ticket_id.clone(),
|
.as_ref()
|
||||||
READY_FIXTURE_TICKET_TITLE,
|
.and_then(|backend| backend.backend_target().ok())
|
||||||
"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 panel_config(&self, binary: PathBuf) -> PanelHarnessConfig {
|
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 {
|
PanelHarnessConfig {
|
||||||
binary,
|
binary,
|
||||||
workspace: self.workspace.clone(),
|
workspace: self.workspace.clone(),
|
||||||
@@ -1514,11 +1437,7 @@ impl FixtureWorkspace {
|
|||||||
terminal_size: (100, 32),
|
terminal_size: (100, 32),
|
||||||
hold_background_task: None,
|
hold_background_task: None,
|
||||||
rewind_fixture: false,
|
rewind_fixture: false,
|
||||||
command_args: vec![
|
command_args,
|
||||||
"panel".to_string(),
|
|
||||||
"--workspace".to_string(),
|
|
||||||
self.workspace.display().to_string(),
|
|
||||||
],
|
|
||||||
artifacts_dir: self.artifacts_dir.clone(),
|
artifacts_dir: self.artifacts_dir.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1551,18 +1470,34 @@ impl FixtureWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cleanup_inner(&mut self, strict: bool) -> Result<FixtureCleanupReport> {
|
fn cleanup_inner(&mut self, strict: bool) -> Result<FixtureCleanupReport> {
|
||||||
|
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");
|
let snapshot_dir = self.artifacts_dir.join("fixture-snapshot");
|
||||||
if snapshot_dir.exists() {
|
if snapshot_dir.exists() {
|
||||||
fs::remove_dir_all(&snapshot_dir)?;
|
fs::remove_dir_all(&snapshot_dir)?;
|
||||||
}
|
}
|
||||||
copy_dir_recursive(&self.root, &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 Some(temp_root) = self.temp_root.take() {
|
&& let Err(err) = temp_root.close()
|
||||||
if let Err(err) = temp_root.close() {
|
{
|
||||||
cleanup_error = Some(err.to_string());
|
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 fixture_root_exists_after = self.root.exists();
|
||||||
let cleanup_success = cleanup_error.is_none() && !fixture_root_exists_after;
|
let cleanup_success = cleanup_error.is_none() && !fixture_root_exists_after;
|
||||||
let report = FixtureCleanupReport {
|
let report = FixtureCleanupReport {
|
||||||
@@ -1609,7 +1544,7 @@ impl FixtureWorkspace {
|
|||||||
"ready": {
|
"ready": {
|
||||||
"id": &self.ready_ticket_id,
|
"id": &self.ready_ticket_id,
|
||||||
"title": READY_FIXTURE_TICKET_TITLE,
|
"title": READY_FIXTURE_TICKET_TITLE,
|
||||||
"state": "ready"
|
"state": "planning"
|
||||||
},
|
},
|
||||||
"planning": {
|
"planning": {
|
||||||
"id": &self.planning_ticket_id,
|
"id": &self.planning_ticket_id,
|
||||||
@@ -1622,8 +1557,10 @@ impl FixtureWorkspace {
|
|||||||
"host_runtime_inherited": false,
|
"host_runtime_inherited": false,
|
||||||
"host_xdg_runtime_dir_present": std::env::var_os("XDG_RUNTIME_DIR").is_some(),
|
"host_xdg_runtime_dir_present": std::env::var_os("XDG_RUNTIME_DIR").is_some(),
|
||||||
"tested_yoi_runtime_source": "fixture XDG_RUNTIME_DIR",
|
"tested_yoi_runtime_source": "fixture XDG_RUNTIME_DIR",
|
||||||
"tested_yoi_worker_allocation": self.xdg_runtime_dir.join("yoi").join("workers.json"),
|
"backend_target": self.backend_target().map(|(base_url, workspace_id)| serde_json::json!({
|
||||||
"fixture_worker_metadata_root": active_worker_metadata_root(&self.home)
|
"base_url": base_url,
|
||||||
|
"workspace_id": workspace_id,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
"tested_yoi_env_policy": tested_yoi_env_policy_overview(),
|
"tested_yoi_env_policy": tested_yoi_env_policy_overview(),
|
||||||
"cleanup": cleanup,
|
"cleanup": cleanup,
|
||||||
@@ -1834,145 +1771,6 @@ fn open_pty(size: (u16, u16)) -> Result<(File, File)> {
|
|||||||
Ok((master, slave))
|
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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<()> {
|
fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
|
||||||
if !source.exists() {
|
if !source.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1998,17 +1796,6 @@ fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
|
|||||||
Ok(())
|
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 {
|
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[?1000h", b"\x1b[?1000l")
|
||||||
&& mouse_mode_enabled(output, b"\x1b[?1006h", b"\x1b[?1006l")
|
&& mouse_mode_enabled(output, b"\x1b[?1006h", b"\x1b[?1006l")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+137
-602
@@ -1,663 +1,198 @@
|
|||||||
|
use std::fs;
|
||||||
use std::time::{Duration, Instant};
|
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 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::{
|
|
||||||
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<RenderedPanelRow>) -> 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]
|
#[test]
|
||||||
fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() {
|
fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_ctrl_c()
|
||||||
let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready")
|
-> yoi_e2e::Result<()> {
|
||||||
.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<()> {
|
|
||||||
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_backend_fixture_is_isolated(&fixture);
|
||||||
let ready_ticket = fixture.ready_fixture_ticket_row();
|
let ready_ticket_id = fixture.ready_ticket_id.clone();
|
||||||
|
let planning_ticket_id = fixture.planning_ticket_id.clone();
|
||||||
let started = Instant::now();
|
let (_, workspace_id) = fixture
|
||||||
let mut panel =
|
.backend_target()
|
||||||
PanelHarness::spawn(fixture.panel_config_holding_background_task(binary, "reload"))?;
|
.expect("panel fixture should own a Backend target");
|
||||||
let remaining = FIRST_VISIBLE_RENDER_BUDGET
|
let workspace_id = workspace_id.to_string();
|
||||||
.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();
|
|
||||||
|
|
||||||
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))?;
|
||||||
let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET
|
panel.wait_for_first_visible_frame(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 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!(
|
assert!(
|
||||||
first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET,
|
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()
|
panel.artifacts().dir.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET
|
let ready = panel.wait_for(
|
||||||
.checked_sub(started.elapsed())
|
"Backend dashboard content ready with fixture Tickets",
|
||||||
.unwrap_or_else(|| Duration::from_millis(0));
|
DASHBOARD_CONTENT_READY_BUDGET,
|
||||||
let content_ready =
|
|event| {
|
||||||
panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?;
|
event.event == "backend_dashboard_content_ready"
|
||||||
assert!(
|
&& event.data.get("workspace_id").and_then(Value::as_str)
|
||||||
content_ready.snapshot.header.ticket_configured,
|
== Some(workspace_id.as_str())
|
||||||
"dashboard content ready must include usable Ticket configuration; artifacts at {}",
|
&& has_backend_ticket(
|
||||||
panel.artifacts().dir.display()
|
&event.data,
|
||||||
);
|
&ready_ticket_id,
|
||||||
assert!(
|
"Planning E2E Ticket A",
|
||||||
content_ready.snapshot.header.companion.is_some()
|
"planning",
|
||||||
&& content_ready.snapshot.header.orchestrator.is_some(),
|
)
|
||||||
"dashboard content ready must include Companion and Orchestrator header status; got {:?}; artifacts at {}",
|
&& has_backend_ticket(
|
||||||
content_ready.snapshot.header,
|
&event.data,
|
||||||
panel.artifacts().dir.display()
|
&planning_ticket_id,
|
||||||
);
|
"Planning E2E Ticket B",
|
||||||
|
"planning",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
content_ready.snapshot_for_expected(&expected_content),
|
ready.data.get("focus").and_then(Value::as_str),
|
||||||
expected_content.snapshot(),
|
Some("tickets")
|
||||||
"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
|
started.elapsed() <= DASHBOARD_CONTENT_READY_BUDGET,
|
||||||
&& content_ready.categories.planning_ticket_rows > 0
|
"Backend dashboard content missed startup budget; artifacts at {}",
|
||||||
&& 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 {}",
|
|
||||||
panel.artifacts().dir.display()
|
panel.artifacts().dir.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let source_breakdown = panel.expect_dashboard_source_breakdown()?;
|
panel.press(KeyPress::Text("j".to_string()))?;
|
||||||
assert!(
|
panel.wait_for(
|
||||||
source_breakdown.has_source("pod_metadata_status_probe.initial")
|
"Backend dashboard Ticket selection change",
|
||||||
&& source_breakdown.has_source("ticket_config_probe")
|
Duration::from_secs(2),
|
||||||
&& source_breakdown.has_source("local_claim_scan")
|
|event| {
|
||||||
&& source_breakdown.has_source("ticket_scan_parse")
|
event.event == "backend_dashboard_selection_changed"
|
||||||
&& source_breakdown.has_source("orchestration_overlay_validation_read_git")
|
&& event.data.get("selected_ticket_id").and_then(Value::as_str)
|
||||||
&& source_breakdown.has_source("workspace_panel.build.total"),
|
== Some(planning_ticket_id.as_str())
|
||||||
"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()
|
|
||||||
);
|
panel.press(KeyPress::Text("r".to_string()))?;
|
||||||
eprintln!(
|
panel.wait_for(
|
||||||
"panel dashboard source breakdown: {:?}; artifacts at {}",
|
"Backend dashboard reload",
|
||||||
source_breakdown,
|
Duration::from_secs(2),
|
||||||
panel.artifacts().dir.display()
|
|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)?;
|
panel.press(KeyPress::CtrlC)?;
|
||||||
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
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);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 binary = yoi_binary()?;
|
||||||
let fixture = FixtureWorkspace::new(&binary)?;
|
let fixture = FixtureWorkspace::new(&binary)?;
|
||||||
assert_fixture_paths_are_isolated(&fixture);
|
assert_backend_fixture_is_isolated(&fixture);
|
||||||
let expected_content = fixture.expected_dashboard_content();
|
let ready_ticket_id = fixture.ready_ticket_id.clone();
|
||||||
|
|
||||||
let (mut panel, started) = PanelHarness::spawn_via_shell_enter(fixture.panel_config(binary))?;
|
let (mut panel, started) = PanelHarness::spawn_via_shell_enter(fixture.panel_config(binary))?;
|
||||||
let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET
|
panel.wait_for_first_visible_frame(FIRST_VISIBLE_RENDER_BUDGET)?;
|
||||||
.checked_sub(started.elapsed())
|
panel.wait_for(
|
||||||
.unwrap_or_else(|| Duration::from_millis(0));
|
"shell-enter Backend dashboard content",
|
||||||
panel.wait_for_first_visible_frame(first_visible_remaining)?;
|
DASHBOARD_CONTENT_READY_BUDGET,
|
||||||
let first_visible_elapsed = started.elapsed();
|
|event| {
|
||||||
|
event.event == "backend_dashboard_content_ready"
|
||||||
let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET
|
&& has_backend_ticket(
|
||||||
.checked_sub(started.elapsed())
|
&event.data,
|
||||||
.unwrap_or_else(|| Duration::from_millis(0));
|
&ready_ticket_id,
|
||||||
let content_ready =
|
"Planning E2E Ticket A",
|
||||||
panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?;
|
"planning",
|
||||||
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()
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET,
|
started.elapsed() <= DASHBOARD_CONTENT_READY_BUDGET,
|
||||||
"shell-enter dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}",
|
"shell-enter Backend dashboard content missed startup budget; artifacts at {}",
|
||||||
panel.artifacts().dir.display()
|
panel.artifacts().dir.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
let source_breakdown = panel.expect_dashboard_source_breakdown()?;
|
panel.press(KeyPress::Esc)?;
|
||||||
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)?;
|
|
||||||
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
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);
|
drop(panel);
|
||||||
assert_fixture_cleanup(fixture.cleanup()?);
|
let report = fixture.cleanup()?;
|
||||||
|
assert_fixture_cleanup(&report);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn has_backend_ticket(data: &Value, id: &str, title: &str, workflow_state: &str) -> bool {
|
||||||
fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> {
|
data.get("tickets")
|
||||||
let binary = yoi_binary()?;
|
.and_then(Value::as_array)
|
||||||
let fixture = FixtureWorkspace::new(&binary)?;
|
.is_some_and(|tickets| {
|
||||||
assert_fixture_paths_are_isolated(&fixture);
|
tickets.iter().any(|ticket| {
|
||||||
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
|
ticket.get("id").and_then(Value::as_str) == Some(id)
|
||||||
|
&& ticket.get("title").and_then(Value::as_str) == Some(title)
|
||||||
panel.expect_mouse_capture_enabled()?;
|
&& ticket.get("workflow_state").and_then(Value::as_str) == Some(workflow_state)
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn assert_backend_fixture_is_isolated(fixture: &FixtureWorkspace) {
|
||||||
fn panel_mouse_wheel_moves_selection_without_full_drag_capture() -> yoi_e2e::Result<()> {
|
assert!(fixture.root.exists());
|
||||||
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"
|
|
||||||
);
|
|
||||||
assert!(fixture.workspace.starts_with(&fixture.root));
|
assert!(fixture.workspace.starts_with(&fixture.root));
|
||||||
assert!(fixture.home.starts_with(&fixture.root));
|
assert!(fixture.home.starts_with(&fixture.root));
|
||||||
assert!(fixture.xdg_data_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_state_home.starts_with(&fixture.root));
|
||||||
assert!(fixture.xdg_config_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.xdg_runtime_dir.starts_with(&fixture.root));
|
||||||
assert!(
|
assert!(!fixture.workspace.join(".yoi").exists());
|
||||||
!fixture.artifacts_dir.starts_with(&fixture.root),
|
assert!(fixture.backend_target().is_some());
|
||||||
"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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_no_runtime_or_host_pod_leak(
|
fn assert_fixture_cleanup(report: &FixtureCleanupReport) {
|
||||||
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::<Vec<_>>()
|
|
||||||
.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) {
|
|
||||||
assert!(
|
assert!(
|
||||||
report.cleanup_success,
|
report.cleanup_success,
|
||||||
"fixture cleanup failed; report at {}: {:?}",
|
"fixture cleanup failed; report at {}: {:?}",
|
||||||
report.report_path.display(),
|
report.report_path.display(),
|
||||||
report.cleanup_error
|
report.cleanup_error
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(!report.fixture_root.exists());
|
||||||
!report.fixture_root.exists(),
|
assert!(report.report_path.exists());
|
||||||
"fixture temp root should be removed after scenario: {}",
|
assert!(report.snapshot_dir.exists());
|
||||||
report.fixture_root.display()
|
let snapshot = fs::read_to_string(report.snapshot_dir.join("../fixture.json")).unwrap();
|
||||||
);
|
assert!(!snapshot.contains("yoi_api_"));
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use yoi_e2e::{FixtureWorkspace, KeyPress, PanelHarness, yoi_binary};
|
|||||||
fn single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter()
|
fn single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter()
|
||||||
-> yoi_e2e::Result<()> {
|
-> yoi_e2e::Result<()> {
|
||||||
let binary = yoi_binary()?;
|
let binary = yoi_binary()?;
|
||||||
let fixture = FixtureWorkspace::new(&binary)?;
|
let fixture = FixtureWorkspace::new_rewind(&binary)?;
|
||||||
let mut tui = PanelHarness::spawn(fixture.rewind_fixture_config(binary))?;
|
let mut tui = PanelHarness::spawn(fixture.rewind_fixture_config(binary))?;
|
||||||
|
|
||||||
tui.expect_mouse_capture_enabled()?;
|
tui.expect_mouse_capture_enabled()?;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use yoi_e2e::{Result, WorkerE2eEnvironment, WorkerEnvironmentCleanupReport};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starts_server_and_runtime_from_empty_storage() -> Result<()> {
|
||||||
|
let mut environment = WorkerE2eEnvironment::new()?;
|
||||||
|
assert!(!environment.server_data_dir.exists());
|
||||||
|
assert!(!environment.runtime_data_dir.exists());
|
||||||
|
|
||||||
|
environment.start()?;
|
||||||
|
|
||||||
|
assert!(environment.server_data_dir.join("server.db").is_file());
|
||||||
|
assert!(
|
||||||
|
environment
|
||||||
|
.runtime_data_dir
|
||||||
|
.join("runtime/runtime.json")
|
||||||
|
.is_file()
|
||||||
|
);
|
||||||
|
environment.assert_connected_and_empty()?;
|
||||||
|
let artifacts_dir = environment.artifacts_dir.clone();
|
||||||
|
let report = environment.cleanup()?;
|
||||||
|
assert!(report.cleanup_success);
|
||||||
|
let persisted: WorkerEnvironmentCleanupReport =
|
||||||
|
serde_json::from_slice(&fs::read(artifacts_dir.join("cleanup.json"))?)?;
|
||||||
|
assert!(persisted.cleanup_success);
|
||||||
|
assert!(!persisted.fixture_root_exists_after);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_environments_are_isolated_and_cleanup_is_local() -> Result<()> {
|
||||||
|
let mut first = WorkerE2eEnvironment::new()?;
|
||||||
|
let mut second = WorkerE2eEnvironment::new()?;
|
||||||
|
assert_ne!(first.root, second.root);
|
||||||
|
assert_ne!(first.server_data_dir, second.server_data_dir);
|
||||||
|
assert_ne!(first.runtime_data_dir, second.runtime_data_dir);
|
||||||
|
assert_ne!(first.runtime_id, second.runtime_id);
|
||||||
|
assert_ne!(first.account_handle, second.account_handle);
|
||||||
|
assert_ne!(first.artifacts_dir, second.artifacts_dir);
|
||||||
|
|
||||||
|
first.start()?;
|
||||||
|
second.start()?;
|
||||||
|
|
||||||
|
assert_ne!(first.server_port()?, second.server_port()?);
|
||||||
|
assert_ne!(first.runtime_port()?, second.runtime_port()?);
|
||||||
|
assert_ne!(first.server_pid(), second.server_pid());
|
||||||
|
assert_ne!(first.runtime_pid(), second.runtime_pid());
|
||||||
|
assert_ne!(first.workspace_id, second.workspace_id);
|
||||||
|
first.assert_connected_and_empty()?;
|
||||||
|
second.assert_connected_and_empty()?;
|
||||||
|
|
||||||
|
let second_root = second.root.clone();
|
||||||
|
let second_server_data = second.server_data_dir.clone();
|
||||||
|
let second_runtime_data = second.runtime_data_dir.clone();
|
||||||
|
let second_artifacts = second.artifacts_dir.clone();
|
||||||
|
let first_artifacts = first.artifacts_dir.clone();
|
||||||
|
let first_report = first.cleanup()?;
|
||||||
|
assert!(first_report.cleanup_success);
|
||||||
|
assert!(first_artifacts.is_dir());
|
||||||
|
|
||||||
|
assert!(second_root.is_dir());
|
||||||
|
assert!(second_server_data.is_dir());
|
||||||
|
assert!(second_runtime_data.is_dir());
|
||||||
|
assert!(second_artifacts.is_dir());
|
||||||
|
second.assert_connected_and_empty()?;
|
||||||
|
|
||||||
|
let second_report = second.cleanup()?;
|
||||||
|
assert!(second_report.cleanup_success);
|
||||||
|
assert!(second_artifacts.join("cleanup.json").is_file());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user