fix(e2e): close worker environment review gaps
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use server_api::{
|
||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
@@ -22,6 +22,21 @@ use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
|
||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BackendObjectiveListResponse {
|
||||
workspace_id: String,
|
||||
limit: usize,
|
||||
items: Vec<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.
|
||||
///
|
||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
||||
@@ -177,7 +192,23 @@ impl BackendWorkspaceProductClient {
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> 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> {
|
||||
@@ -794,7 +825,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#;
|
||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
@@ -806,6 +837,13 @@ mod tests {
|
||||
let response = client.list_objectives(1_000).unwrap();
|
||||
|
||||
assert!(response.items.is_empty());
|
||||
assert_eq!(response.source, "sqlite");
|
||||
assert_eq!(response.diagnostics.len(), 1);
|
||||
assert_eq!(response.diagnostics[0].code, "invalid_objective_record");
|
||||
assert_eq!(
|
||||
response.diagnostics[0].message,
|
||||
"broken.md: invalid frontmatter"
|
||||
);
|
||||
let request = request.recv().unwrap();
|
||||
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
||||
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{BackendWorkspaceProductClient, ObjectiveSummary};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
@@ -79,8 +79,26 @@ async fn run_loop(
|
||||
app: &mut BackendDashboard,
|
||||
client: BackendWorkspaceProductClient,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut first_frame = true;
|
||||
let mut initial_snapshot_pending = false;
|
||||
loop {
|
||||
terminal.draw(|frame| draw(frame, app))?;
|
||||
if first_frame {
|
||||
first_frame = false;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
{
|
||||
crate::e2e_observer::emit(
|
||||
"backend_dashboard",
|
||||
"panel_ready",
|
||||
serde_json::json!({ "workspace_id": app.workspace_id }),
|
||||
);
|
||||
initial_snapshot_pending = true;
|
||||
}
|
||||
} else if initial_snapshot_pending {
|
||||
#[cfg(feature = "e2e-test")]
|
||||
emit_e2e_snapshot(app, "backend_dashboard_content_ready");
|
||||
initial_snapshot_pending = false;
|
||||
}
|
||||
if !event::poll(Duration::from_millis(100))? {
|
||||
continue;
|
||||
}
|
||||
@@ -91,21 +109,50 @@ async fn run_loop(
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||
KeyCode::Char('q') | KeyCode::Esc => {
|
||||
#[cfg(feature = "e2e-test")]
|
||||
crate::e2e_observer::emit(
|
||||
"backend_dashboard",
|
||||
"quit_requested",
|
||||
serde_json::json!({}),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
#[cfg(feature = "e2e-test")]
|
||||
crate::e2e_observer::emit(
|
||||
"backend_dashboard",
|
||||
"quit_requested",
|
||||
serde_json::json!({}),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Tab | KeyCode::BackTab => {
|
||||
app.focus = match app.focus {
|
||||
Focus::Tickets => Focus::Objectives,
|
||||
Focus::Objectives => Focus::Tickets,
|
||||
};
|
||||
#[cfg(feature = "e2e-test")]
|
||||
emit_e2e_snapshot(app, "backend_dashboard_selection_changed");
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
app.select_next();
|
||||
#[cfg(feature = "e2e-test")]
|
||||
emit_e2e_snapshot(app, "backend_dashboard_selection_changed");
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
app.select_previous();
|
||||
#[cfg(feature = "e2e-test")]
|
||||
emit_e2e_snapshot(app, "backend_dashboard_selection_changed");
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => app.select_next(),
|
||||
KeyCode::Up | KeyCode::Char('k') => app.select_previous(),
|
||||
KeyCode::Char('r') => match load(&client).await {
|
||||
Ok((tickets, objectives)) => {
|
||||
app.tickets = tickets;
|
||||
app.objectives = objectives;
|
||||
app.clamp_selection();
|
||||
app.status = "Reloaded from Backend authority".to_string();
|
||||
#[cfg(feature = "e2e-test")]
|
||||
emit_e2e_snapshot(app, "backend_dashboard_reloaded");
|
||||
}
|
||||
Err(error) => app.status = format!("Backend reload failed: {error}"),
|
||||
},
|
||||
@@ -144,6 +191,51 @@ async fn run_loop(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test")]
|
||||
fn emit_e2e_snapshot(app: &BackendDashboard, event: &'static str) {
|
||||
let focus = match app.focus {
|
||||
Focus::Tickets => "tickets",
|
||||
Focus::Objectives => "objectives",
|
||||
};
|
||||
let tickets = app
|
||||
.tickets
|
||||
.iter()
|
||||
.map(|ticket| {
|
||||
serde_json::json!({
|
||||
"id": ticket.id,
|
||||
"resource_key": ticket.resource_key,
|
||||
"title": ticket.title,
|
||||
"workflow_state": ticket.workflow_state.as_str(),
|
||||
})
|
||||
})
|
||||
.collect::<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 {
|
||||
fn select_next(&mut self) {
|
||||
match self.focus {
|
||||
|
||||
@@ -2,12 +2,11 @@ use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
const EVENT_PATH_ENV: &str = "YOI_TUI_TEST_EVENTS";
|
||||
const HOLD_BACKGROUND_TASK_ENV: &str = "YOI_TUI_TEST_HOLD_BACKGROUND_TASK";
|
||||
|
||||
static EVENT_WRITER: OnceLock<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>> {
|
||||
let path = std::env::var_os(EVENT_PATH_ENV).map(PathBuf::from)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
@@ -140,25 +140,11 @@ impl FsRuntimeStore {
|
||||
})?;
|
||||
}
|
||||
let snapshot_exists = root.join(RUNTIME_FILE).is_file();
|
||||
let workers_dir = root.join(WORKERS_DIR);
|
||||
if !snapshot_exists
|
||||
&& workers_dir.is_dir()
|
||||
&& fs::read_dir(&workers_dir)
|
||||
.map_err(|source| RuntimeError::StoreIo {
|
||||
operation: "inspect uninitialized runtime store",
|
||||
path: workers_dir.clone(),
|
||||
source,
|
||||
})?
|
||||
.next()
|
||||
.is_some()
|
||||
{
|
||||
return Err(RuntimeError::StoreCorrupt {
|
||||
operation: "open runtime store",
|
||||
path: root,
|
||||
message: "Worker records exist without a Runtime snapshot".to_string(),
|
||||
});
|
||||
if root_existed && !snapshot_exists {
|
||||
validate_fresh_runtime_store_layout(&root)?;
|
||||
}
|
||||
|
||||
let workers_dir = root.join(WORKERS_DIR);
|
||||
fs::create_dir_all(&workers_dir).map_err(|source| RuntimeError::StoreIo {
|
||||
operation: "create runtime store",
|
||||
path: workers_dir,
|
||||
@@ -362,6 +348,38 @@ impl FsRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_fresh_runtime_store_layout(root: &Path) -> Result<(), RuntimeError> {
|
||||
let entries = fs::read_dir(root).map_err(|source| RuntimeError::StoreIo {
|
||||
operation: "inspect uninitialized runtime store",
|
||||
path: root.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|source| RuntimeError::StoreIo {
|
||||
operation: "inspect uninitialized runtime store",
|
||||
path: root.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let file_type = entry.file_type().map_err(|source| RuntimeError::StoreIo {
|
||||
operation: "inspect uninitialized runtime store entry",
|
||||
path: entry.path(),
|
||||
source,
|
||||
})?;
|
||||
if entry.file_name() == "auth.toml" && file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
return Err(RuntimeError::StoreCorrupt {
|
||||
operation: "open runtime store",
|
||||
path: root.to_path_buf(),
|
||||
message: format!(
|
||||
"unexpected persistence `{}` exists without a Runtime snapshot",
|
||||
entry.file_name().to_string_lossy()
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn acquire_runtime_store_owner_lock(
|
||||
root: &Path,
|
||||
) -> Result<Arc<RuntimeStoreOwnerLock>, RuntimeError> {
|
||||
@@ -2173,6 +2191,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshotless_directory_rejects_non_auth_persistence() {
|
||||
for unexpected in ["events.jsonl", "unknown.json"] {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let root = parent.path().join("runtime");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
fs::write(root.join("auth.toml"), b"fixture auth material").unwrap();
|
||||
fs::write(root.join(unexpected), b"must remain").unwrap();
|
||||
|
||||
let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RuntimeError::StoreCorrupt { path, .. } if path == root
|
||||
));
|
||||
assert_eq!(fs::read(root.join(unexpected)).unwrap(), b"must remain");
|
||||
assert!(!root.join(WORKERS_DIR).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_archive_without_runtime_snapshot_is_rejected() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let root = parent.path().join("runtime");
|
||||
fs::create_dir_all(root.join(ORPHANED_WORKERS_DIR).join("worker-a")).unwrap();
|
||||
|
||||
let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
RuntimeError::StoreCorrupt { path, .. } if path == root
|
||||
));
|
||||
assert!(root.join(ORPHANED_WORKERS_DIR).join("worker-a").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_records_without_runtime_snapshot_are_rejected() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user