fix(e2e): close worker environment review gaps
This commit is contained in:
+59
-311
@@ -628,6 +628,7 @@ impl PanelHarness {
|
||||
|
||||
let mut command = Command::new(&config.binary);
|
||||
command
|
||||
.current_dir(&config.workspace)
|
||||
.args(&config.command_args)
|
||||
.env_clear()
|
||||
.env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl)
|
||||
@@ -1338,8 +1339,8 @@ pub struct FixtureCleanupReport {
|
||||
pub report_path: PathBuf,
|
||||
}
|
||||
|
||||
pub const READY_FIXTURE_TICKET_TITLE: &str = "Ready E2E Ticket";
|
||||
pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket";
|
||||
pub const READY_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket A";
|
||||
pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket B";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FixtureWorkspace {
|
||||
@@ -1354,6 +1355,7 @@ pub struct FixtureWorkspace {
|
||||
pub artifacts_dir: PathBuf,
|
||||
pub ready_ticket_id: String,
|
||||
pub planning_ticket_id: String,
|
||||
backend_environment: Option<WorkerE2eEnvironment>,
|
||||
}
|
||||
|
||||
impl FixtureWorkspace {
|
||||
@@ -1365,7 +1367,7 @@ impl FixtureWorkspace {
|
||||
Self::new_inner(binary, false)
|
||||
}
|
||||
|
||||
fn new_inner(binary: &Path, setup_dashboard: bool) -> Result<Self> {
|
||||
fn new_inner(_binary: &Path, setup_dashboard: bool) -> Result<Self> {
|
||||
let allocation = allocate_fixture("tui")?;
|
||||
let root = allocation.root;
|
||||
let artifacts_dir = allocation.artifacts_dir;
|
||||
@@ -1384,163 +1386,45 @@ impl FixtureWorkspace {
|
||||
artifacts_dir,
|
||||
ready_ticket_id: String::new(),
|
||||
planning_ticket_id: String::new(),
|
||||
backend_environment: None,
|
||||
};
|
||||
fixture.write_fixture_metadata("created", None)?;
|
||||
|
||||
if setup_dashboard {
|
||||
let worker_metadata_root = active_worker_metadata_root(&fixture.home);
|
||||
write_blocking_worker_metadata(&worker_metadata_root, "workspace")?;
|
||||
write_blocking_worker_metadata(&worker_metadata_root, "workspace-orchestrator")?;
|
||||
run_yoi(
|
||||
binary,
|
||||
&fixture.workspace,
|
||||
&fixture.home,
|
||||
&fixture.xdg_data_home,
|
||||
&fixture.xdg_state_home,
|
||||
&fixture.xdg_config_home,
|
||||
&fixture.xdg_runtime_dir,
|
||||
&fixture.artifacts_dir,
|
||||
&["ticket", "init"],
|
||||
)?;
|
||||
let first = create_ticket(
|
||||
binary,
|
||||
&fixture.workspace,
|
||||
&fixture.home,
|
||||
&fixture.xdg_data_home,
|
||||
&fixture.xdg_state_home,
|
||||
&fixture.xdg_config_home,
|
||||
&fixture.xdg_runtime_dir,
|
||||
&fixture.artifacts_dir,
|
||||
READY_FIXTURE_TICKET_TITLE,
|
||||
)?;
|
||||
run_yoi(
|
||||
binary,
|
||||
&fixture.workspace,
|
||||
&fixture.home,
|
||||
&fixture.xdg_data_home,
|
||||
&fixture.xdg_state_home,
|
||||
&fixture.xdg_config_home,
|
||||
&fixture.xdg_runtime_dir,
|
||||
&fixture.artifacts_dir,
|
||||
&["ticket", "state", &first, "ready"],
|
||||
)?;
|
||||
let second = create_ticket(
|
||||
binary,
|
||||
&fixture.workspace,
|
||||
&fixture.home,
|
||||
&fixture.xdg_data_home,
|
||||
&fixture.xdg_state_home,
|
||||
&fixture.xdg_config_home,
|
||||
&fixture.xdg_runtime_dir,
|
||||
&fixture.artifacts_dir,
|
||||
PLANNING_FIXTURE_TICKET_TITLE,
|
||||
)?;
|
||||
fixture.ready_ticket_id = first;
|
||||
fixture.planning_ticket_id = second;
|
||||
fixture.setup_orchestration_overlay(binary)?;
|
||||
let mut backend = WorkerE2eEnvironment::new()?;
|
||||
backend.start_backend()?;
|
||||
backend.install_backend_token(&fixture.xdg_config_home)?;
|
||||
fixture.ready_ticket_id = backend.create_planning_ticket(READY_FIXTURE_TICKET_TITLE)?;
|
||||
fixture.planning_ticket_id =
|
||||
backend.create_planning_ticket(PLANNING_FIXTURE_TICKET_TITLE)?;
|
||||
backend.capture_panel_seed()?;
|
||||
fixture.backend_environment = Some(backend);
|
||||
}
|
||||
fixture.write_fixture_metadata("ready", None)?;
|
||||
Ok(fixture)
|
||||
}
|
||||
|
||||
pub fn ready_fixture_ticket_row(&self) -> ExpectedPanelTicketRow {
|
||||
ExpectedPanelTicketRow::new(
|
||||
self.ready_ticket_id.clone(),
|
||||
READY_FIXTURE_TICKET_TITLE,
|
||||
"ready",
|
||||
)
|
||||
.with_action("Queue")
|
||||
.with_local_state("ready")
|
||||
}
|
||||
|
||||
pub fn ready_overlay_ticket_row(&self) -> ExpectedPanelTicketRow {
|
||||
ExpectedPanelTicketRow::new(
|
||||
self.ready_ticket_id.clone(),
|
||||
READY_FIXTURE_TICKET_TITLE,
|
||||
"ready→prog",
|
||||
)
|
||||
.with_action("Wait")
|
||||
.with_disabled_reason("orchestration worktree overlay shows Ticket state inprogress")
|
||||
.with_local_state("ready")
|
||||
.with_overlay_state("inprogress")
|
||||
}
|
||||
|
||||
pub fn planning_fixture_ticket_row(&self) -> ExpectedPanelTicketRow {
|
||||
ExpectedPanelTicketRow::new(
|
||||
self.planning_ticket_id.clone(),
|
||||
PLANNING_FIXTURE_TICKET_TITLE,
|
||||
"planning",
|
||||
)
|
||||
.with_action("Clarify")
|
||||
.with_disabled_reason("Ticket is still in planning")
|
||||
.with_local_state("planning")
|
||||
}
|
||||
|
||||
pub fn expected_dashboard_content(&self) -> ExpectedDashboardContent {
|
||||
ExpectedDashboardContent {
|
||||
tickets: vec![
|
||||
self.ready_overlay_ticket_row(),
|
||||
self.planning_fixture_ticket_row(),
|
||||
],
|
||||
worker_names: vec!["workspace".to_string()],
|
||||
companion_status: "spawned".to_string(),
|
||||
orchestrator_status: "unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_orchestration_overlay(&self, binary: &Path) -> Result<()> {
|
||||
run_git(&self.workspace, &["init"])?;
|
||||
run_git(&self.workspace, &["checkout", "-B", "develop"])?;
|
||||
run_git(
|
||||
&self.workspace,
|
||||
&["config", "user.email", "fixture@example.invalid"],
|
||||
)?;
|
||||
run_git(&self.workspace, &["config", "user.name", "Yoi E2E Fixture"])?;
|
||||
run_git(&self.workspace, &["add", ".yoi"])?;
|
||||
run_git(&self.workspace, &["commit", "-m", "fixture tickets"])?;
|
||||
let orchestration = self.workspace.join(".worktree/orchestration");
|
||||
run_git(
|
||||
&self.workspace,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
"orchestration",
|
||||
orchestration.to_string_lossy().as_ref(),
|
||||
"HEAD",
|
||||
],
|
||||
)?;
|
||||
run_yoi(
|
||||
binary,
|
||||
&orchestration,
|
||||
&self.home,
|
||||
&self.xdg_data_home,
|
||||
&self.xdg_state_home,
|
||||
&self.xdg_config_home,
|
||||
&self.xdg_runtime_dir,
|
||||
&self.artifacts_dir,
|
||||
&["ticket", "state", &self.ready_ticket_id, "queued"],
|
||||
)?;
|
||||
run_yoi(
|
||||
binary,
|
||||
&orchestration,
|
||||
&self.home,
|
||||
&self.xdg_data_home,
|
||||
&self.xdg_state_home,
|
||||
&self.xdg_config_home,
|
||||
&self.xdg_runtime_dir,
|
||||
&self.artifacts_dir,
|
||||
&["ticket", "state", &self.ready_ticket_id, "inprogress"],
|
||||
)?;
|
||||
run_git(&orchestration, &["add", ".yoi"])?;
|
||||
run_git(
|
||||
&orchestration,
|
||||
&["commit", "-m", "fixture orchestration overlay"],
|
||||
)?;
|
||||
Ok(())
|
||||
pub fn backend_target(&self) -> Option<(&str, &str)> {
|
||||
self.backend_environment
|
||||
.as_ref()
|
||||
.and_then(|backend| backend.backend_target().ok())
|
||||
}
|
||||
|
||||
pub fn panel_config(&self, binary: PathBuf) -> PanelHarnessConfig {
|
||||
let command_args = if let Some(backend) = self.backend_environment.as_ref() {
|
||||
let (server_url, workspace_id) = backend
|
||||
.backend_target()
|
||||
.expect("started Backend panel fixture has a target");
|
||||
vec![
|
||||
"--backend".to_string(),
|
||||
server_url.to_string(),
|
||||
"--workspace-id".to_string(),
|
||||
workspace_id.to_string(),
|
||||
"panel".to_string(),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
PanelHarnessConfig {
|
||||
binary,
|
||||
workspace: self.workspace.clone(),
|
||||
@@ -1553,11 +1437,7 @@ impl FixtureWorkspace {
|
||||
terminal_size: (100, 32),
|
||||
hold_background_task: None,
|
||||
rewind_fixture: false,
|
||||
command_args: vec![
|
||||
"panel".to_string(),
|
||||
"--workspace".to_string(),
|
||||
self.workspace.display().to_string(),
|
||||
],
|
||||
command_args,
|
||||
artifacts_dir: self.artifacts_dir.clone(),
|
||||
}
|
||||
}
|
||||
@@ -1590,18 +1470,34 @@ impl FixtureWorkspace {
|
||||
}
|
||||
|
||||
fn cleanup_inner(&mut self, strict: bool) -> Result<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");
|
||||
if snapshot_dir.exists() {
|
||||
fs::remove_dir_all(&snapshot_dir)?;
|
||||
}
|
||||
copy_dir_recursive(&self.root, &snapshot_dir)?;
|
||||
|
||||
let mut cleanup_error = None;
|
||||
if let Some(temp_root) = self.temp_root.take() {
|
||||
if let Err(err) = temp_root.close() {
|
||||
cleanup_error = Some(err.to_string());
|
||||
}
|
||||
if let Some(temp_root) = self.temp_root.take()
|
||||
&& let Err(err) = temp_root.close()
|
||||
{
|
||||
cleanup_errors.push(err.to_string());
|
||||
}
|
||||
let cleanup_error = (!cleanup_errors.is_empty()).then(|| cleanup_errors.join("; "));
|
||||
let fixture_root_exists_after = self.root.exists();
|
||||
let cleanup_success = cleanup_error.is_none() && !fixture_root_exists_after;
|
||||
let report = FixtureCleanupReport {
|
||||
@@ -1648,7 +1544,7 @@ impl FixtureWorkspace {
|
||||
"ready": {
|
||||
"id": &self.ready_ticket_id,
|
||||
"title": READY_FIXTURE_TICKET_TITLE,
|
||||
"state": "ready"
|
||||
"state": "planning"
|
||||
},
|
||||
"planning": {
|
||||
"id": &self.planning_ticket_id,
|
||||
@@ -1661,8 +1557,10 @@ impl FixtureWorkspace {
|
||||
"host_runtime_inherited": false,
|
||||
"host_xdg_runtime_dir_present": std::env::var_os("XDG_RUNTIME_DIR").is_some(),
|
||||
"tested_yoi_runtime_source": "fixture XDG_RUNTIME_DIR",
|
||||
"tested_yoi_worker_allocation": self.xdg_runtime_dir.join("yoi").join("workers.json"),
|
||||
"fixture_worker_metadata_root": active_worker_metadata_root(&self.home)
|
||||
"backend_target": self.backend_target().map(|(base_url, workspace_id)| serde_json::json!({
|
||||
"base_url": base_url,
|
||||
"workspace_id": workspace_id,
|
||||
})),
|
||||
},
|
||||
"tested_yoi_env_policy": tested_yoi_env_policy_overview(),
|
||||
"cleanup": cleanup,
|
||||
@@ -1873,145 +1771,6 @@ fn open_pty(size: (u16, u16)) -> Result<(File, File)> {
|
||||
Ok((master, slave))
|
||||
}
|
||||
|
||||
fn create_ticket(
|
||||
binary: &Path,
|
||||
workspace: &Path,
|
||||
home: &Path,
|
||||
data: &Path,
|
||||
state: &Path,
|
||||
config: &Path,
|
||||
runtime: &Path,
|
||||
artifacts_dir: &Path,
|
||||
title: &str,
|
||||
) -> Result<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<()> {
|
||||
if !source.exists() {
|
||||
return Ok(());
|
||||
@@ -2037,17 +1796,6 @@ fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_worker_metadata_root(home: &Path) -> PathBuf {
|
||||
home.join(".yoi").join("workers")
|
||||
}
|
||||
|
||||
fn write_blocking_worker_metadata(worker_metadata_root: &Path, worker_name: &str) -> Result<()> {
|
||||
let dir = worker_metadata_root.join(worker_name);
|
||||
fs::create_dir_all(&dir)?;
|
||||
fs::write(dir.join("metadata.json"), b"not valid metadata for e2e\n")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn output_has_enabled_mouse_capture(output: &[u8]) -> bool {
|
||||
mouse_mode_enabled(output, b"\x1b[?1000h", b"\x1b[?1000l")
|
||||
&& mouse_mode_enabled(output, b"\x1b[?1006h", b"\x1b[?1006l")
|
||||
|
||||
@@ -263,6 +263,8 @@ impl WorkerE2eEnvironment {
|
||||
serde_json::to_vec_pretty(&binaries)?,
|
||||
)?;
|
||||
let client = Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(1))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build()
|
||||
@@ -295,6 +297,11 @@ impl WorkerE2eEnvironment {
|
||||
}
|
||||
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
self.start_backend()?;
|
||||
self.start_runtime()
|
||||
}
|
||||
|
||||
pub fn start_backend(&mut self) -> Result<()> {
|
||||
assert_absent_or_empty(&self.server_data_dir, "Server data directory")?;
|
||||
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
|
||||
let server_binary = self.binaries.binary("yoi-server")?;
|
||||
@@ -369,7 +376,14 @@ impl WorkerE2eEnvironment {
|
||||
})?;
|
||||
let bundle_path = self.root.join("workspace-public-identity.json");
|
||||
fs::write(&bundle_path, serde_json::to_vec_pretty(&public_bundle)?)?;
|
||||
self.write_metadata("backend-running")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_runtime(&mut self) -> Result<()> {
|
||||
let bundle_path = self.root.join("workspace-public-identity.json");
|
||||
let server_url = self.required_server_url()?.to_string();
|
||||
let workspace_id = self.required_workspace_id()?.to_string();
|
||||
let runtime_binary = self.binaries.binary("yoi-runtime")?;
|
||||
self.run_command(
|
||||
&runtime_binary,
|
||||
@@ -501,6 +515,91 @@ impl WorkerE2eEnvironment {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn install_backend_token(&self, config_home: &Path) -> Result<PathBuf> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let access_token = self.access_token.as_deref().ok_or_else(|| {
|
||||
HarnessError::Protocol("Backend access token is not initialized".to_string())
|
||||
})?;
|
||||
let token_path = config_home.join("yoi/backend-tokens.json");
|
||||
if let Some(parent) = token_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(
|
||||
&token_path,
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"tokens": {
|
||||
server_url: {
|
||||
"token_type": "Bearer",
|
||||
"access_token": access_token,
|
||||
}
|
||||
}
|
||||
}))?,
|
||||
)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&token_path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(token_path)
|
||||
}
|
||||
|
||||
pub fn create_planning_ticket(&self, title: &str) -> Result<String> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let workspace_id = self.required_workspace_id()?;
|
||||
let created = self.request_json(
|
||||
self.client
|
||||
.post(format!("{server_url}/api/w/{workspace_id}/tickets"))
|
||||
.json(&serde_json::json!({
|
||||
"title": title,
|
||||
"slug": null,
|
||||
"kind": "task",
|
||||
"priority": "P2",
|
||||
"labels": [],
|
||||
"body": "Backend-owned panel E2E fixture.",
|
||||
"author": null,
|
||||
"assignee": null,
|
||||
"readiness": null,
|
||||
"risk_flags": [],
|
||||
"workflow_state": "planning",
|
||||
"queued_by": null,
|
||||
"queued_at": null,
|
||||
"repository_key": null,
|
||||
"ref_selector": null,
|
||||
})),
|
||||
"create Ticket",
|
||||
)?;
|
||||
json_string(&created, "/id")
|
||||
}
|
||||
|
||||
pub fn capture_panel_seed(&self) -> Result<()> {
|
||||
let server_url = self.required_server_url()?;
|
||||
let workspace_id = self.required_workspace_id()?;
|
||||
let tickets = self.request_json(
|
||||
self.client.get(format!(
|
||||
"{server_url}/api/w/{workspace_id}/tickets/search?state=active"
|
||||
)),
|
||||
"list panel fixture Tickets",
|
||||
)?;
|
||||
let objectives = self.request_json(
|
||||
self.client.get(format!(
|
||||
"{server_url}/api/w/{workspace_id}/objectives?limit=1000"
|
||||
)),
|
||||
"list panel fixture Objectives",
|
||||
)?;
|
||||
fs::write(
|
||||
self.artifacts_dir.join("panel-seed.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"tickets": tickets,
|
||||
"objectives": objectives,
|
||||
}))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn backend_target(&self) -> Result<(&str, &str)> {
|
||||
Ok((self.required_server_url()?, self.required_workspace_id()?))
|
||||
}
|
||||
|
||||
pub fn server_port(&self) -> Result<u16> {
|
||||
url_port(self.required_server_url()?)
|
||||
}
|
||||
@@ -652,7 +751,12 @@ impl WorkerE2eEnvironment {
|
||||
"binary": binary,
|
||||
"args": args,
|
||||
"status": output.status.code(),
|
||||
"stdout": if redact_stdout { "<redacted>" } else { "captured-not-persisted" },
|
||||
"stdout": if redact_stdout {
|
||||
"<redacted>".to_string()
|
||||
} else {
|
||||
bounded_text(&output.stdout, MAX_COMMAND_OUTPUT_BYTES)
|
||||
},
|
||||
"stderr": bounded_text(&output.stderr, MAX_COMMAND_OUTPUT_BYTES),
|
||||
"env_clear": true,
|
||||
}),
|
||||
)?;
|
||||
|
||||
+137
-602
@@ -1,663 +1,198 @@
|
||||
use std::fs;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::Value;
|
||||
use yoi_e2e::{FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness, yoi_binary};
|
||||
|
||||
const FIRST_VISIBLE_RENDER_BUDGET: Duration = Duration::from_millis(1500);
|
||||
const DASHBOARD_CONTENT_READY_BUDGET: Duration = Duration::from_secs(5);
|
||||
|
||||
use yoi_e2e::{
|
||||
DashboardCompanionState, DashboardContentCategories, DashboardContentReady, DashboardHeader,
|
||||
DashboardOrchestratorState, DashboardSnapshot, ExpectedDashboardContent,
|
||||
ExpectedPanelTicketRow, FixtureCleanupReport, FixtureWorkspace, KeyPress, PanelHarness,
|
||||
PanelRect, PanelRowKey, RenderedPanelRow, RowsRendered, yoi_binary,
|
||||
};
|
||||
|
||||
fn rendered_ticket_row(
|
||||
id: &str,
|
||||
title: &str,
|
||||
status: &str,
|
||||
action: Option<&str>,
|
||||
disabled_reason: Option<&str>,
|
||||
local_state: Option<&str>,
|
||||
overlay_state: Option<&str>,
|
||||
) -> RenderedPanelRow {
|
||||
RenderedPanelRow {
|
||||
key: PanelRowKey {
|
||||
kind: "ticket".to_string(),
|
||||
id: id.to_string(),
|
||||
},
|
||||
title: title.to_string(),
|
||||
status: Some(status.to_string()),
|
||||
action: action.map(ToOwned::to_owned),
|
||||
disabled_reason: disabled_reason.map(ToOwned::to_owned),
|
||||
local_state: local_state.map(ToOwned::to_owned),
|
||||
overlay_state: overlay_state.map(ToOwned::to_owned),
|
||||
overlay_detail: overlay_state.map(|state| format!("orchestration:{state}")),
|
||||
rect: PanelRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_worker_row(name: &str) -> RenderedPanelRow {
|
||||
RenderedPanelRow {
|
||||
key: PanelRowKey {
|
||||
kind: "worker".to_string(),
|
||||
id: name.to_string(),
|
||||
},
|
||||
title: name.to_string(),
|
||||
status: None,
|
||||
action: None,
|
||||
disabled_reason: None,
|
||||
local_state: None,
|
||||
overlay_state: None,
|
||||
overlay_detail: None,
|
||||
rect: PanelRect {
|
||||
x: 0,
|
||||
y: 1,
|
||||
width: 10,
|
||||
height: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn ready_snapshot(rows: Vec<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]
|
||||
fn panel_fixture_ticket_row_matcher_rejects_absent_fixture_data() {
|
||||
let expected = ExpectedPanelTicketRow::new("0000000000000", "Ready E2E Ticket", "ready")
|
||||
.with_action("Queue")
|
||||
.with_local_state("ready");
|
||||
let wrong_title = rendered_ticket_row(
|
||||
"0000000000000",
|
||||
"Different Ticket",
|
||||
"ready",
|
||||
Some("Queue"),
|
||||
None,
|
||||
Some("ready"),
|
||||
None,
|
||||
);
|
||||
let wrong_kind = RenderedPanelRow {
|
||||
key: PanelRowKey {
|
||||
kind: "worker".to_string(),
|
||||
id: "0000000000000".to_string(),
|
||||
},
|
||||
title: "Ready E2E Ticket".to_string(),
|
||||
status: Some("ready".to_string()),
|
||||
action: Some("Queue".to_string()),
|
||||
disabled_reason: None,
|
||||
local_state: Some("ready".to_string()),
|
||||
overlay_state: None,
|
||||
overlay_detail: None,
|
||||
rect: PanelRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 1,
|
||||
},
|
||||
};
|
||||
|
||||
assert!(!expected.matches(&wrong_title));
|
||||
assert!(!expected.matches(&wrong_kind));
|
||||
let rows = RowsRendered {
|
||||
selected: None,
|
||||
rows: vec![wrong_title, wrong_kind],
|
||||
};
|
||||
assert!(!rows.has_fixture_ticket_row(&expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_snapshot_rejects_missing_row_wrong_state_missing_overlay_and_missing_action() {
|
||||
let expected_ready = ExpectedPanelTicketRow::new("ready-id", "Ready E2E Ticket", "ready→prog")
|
||||
.with_action("Wait")
|
||||
.with_disabled_reason("orchestration worktree overlay shows Ticket state inprogress")
|
||||
.with_local_state("ready")
|
||||
.with_overlay_state("inprogress");
|
||||
let expected_planning =
|
||||
ExpectedPanelTicketRow::new("planning-id", "Planning E2E Ticket", "planning")
|
||||
.with_action("Clarify")
|
||||
.with_disabled_reason("Ticket is still in planning")
|
||||
.with_local_state("planning");
|
||||
let expected = ExpectedDashboardContent {
|
||||
tickets: vec![expected_ready.clone(), expected_planning.clone()],
|
||||
worker_names: vec!["workspace".to_string()],
|
||||
companion_status: "unavailable".to_string(),
|
||||
orchestrator_status: "unavailable".to_string(),
|
||||
};
|
||||
let complete_rows = || {
|
||||
vec![
|
||||
rendered_ticket_row(
|
||||
"ready-id",
|
||||
"Ready E2E Ticket",
|
||||
"ready→prog",
|
||||
Some("Wait"),
|
||||
Some("orchestration worktree overlay shows Ticket state inprogress"),
|
||||
Some("ready"),
|
||||
Some("inprogress"),
|
||||
),
|
||||
rendered_ticket_row(
|
||||
"planning-id",
|
||||
"Planning E2E Ticket",
|
||||
"planning",
|
||||
Some("Clarify"),
|
||||
Some("Ticket is still in planning"),
|
||||
Some("planning"),
|
||||
None,
|
||||
),
|
||||
rendered_worker_row("workspace"),
|
||||
]
|
||||
};
|
||||
assert_eq!(
|
||||
ready_snapshot(complete_rows()).snapshot_for_expected(&expected),
|
||||
expected.snapshot()
|
||||
);
|
||||
|
||||
let missing_row = ready_snapshot(vec![
|
||||
rendered_ticket_row(
|
||||
"ready-id",
|
||||
"Ready E2E Ticket",
|
||||
"ready→prog",
|
||||
Some("Wait"),
|
||||
Some("orchestration worktree overlay shows Ticket state inprogress"),
|
||||
Some("ready"),
|
||||
Some("inprogress"),
|
||||
),
|
||||
rendered_worker_row("workspace"),
|
||||
]);
|
||||
assert_ne!(
|
||||
missing_row.snapshot_for_expected(&expected),
|
||||
expected.snapshot()
|
||||
);
|
||||
|
||||
let mut wrong_state_rows = complete_rows();
|
||||
wrong_state_rows[0].status = Some("ready".to_string());
|
||||
assert_ne!(
|
||||
ready_snapshot(wrong_state_rows).snapshot_for_expected(&expected),
|
||||
expected.snapshot()
|
||||
);
|
||||
|
||||
let mut missing_overlay_rows = complete_rows();
|
||||
missing_overlay_rows[0].overlay_state = None;
|
||||
assert_ne!(
|
||||
ready_snapshot(missing_overlay_rows).snapshot_for_expected(&expected),
|
||||
expected.snapshot()
|
||||
);
|
||||
|
||||
let mut missing_action_rows = complete_rows();
|
||||
missing_action_rows[0].action = None;
|
||||
assert_ne!(
|
||||
ready_snapshot(missing_action_rows).snapshot_for_expected(&expected),
|
||||
expected.snapshot()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_first_visible_render_arrives_before_background_reload() -> yoi_e2e::Result<()> {
|
||||
fn backend_panel_loads_backend_owned_tickets_and_supports_reload_navigation_and_ctrl_c()
|
||||
-> yoi_e2e::Result<()> {
|
||||
let binary = yoi_binary()?;
|
||||
let fixture = FixtureWorkspace::new(&binary)?;
|
||||
assert_fixture_paths_are_isolated(&fixture);
|
||||
let ready_ticket = fixture.ready_fixture_ticket_row();
|
||||
|
||||
let started = Instant::now();
|
||||
let mut panel =
|
||||
PanelHarness::spawn(fixture.panel_config_holding_background_task(binary, "reload"))?;
|
||||
let remaining = FIRST_VISIBLE_RENDER_BUDGET
|
||||
.checked_sub(started.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_millis(0));
|
||||
panel.wait_for_first_visible_frame(remaining)?;
|
||||
let first_visible_elapsed = started.elapsed();
|
||||
eprintln!(
|
||||
"panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
assert!(
|
||||
first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET,
|
||||
"first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
|
||||
let events = panel.events()?;
|
||||
let ready_index = events
|
||||
.iter()
|
||||
.position(|event| event.event == "panel_ready")
|
||||
.expect("panel_ready event should be present");
|
||||
assert!(
|
||||
events[..ready_index]
|
||||
.iter()
|
||||
.all(|event| event.event != "background_task_started"),
|
||||
"initial frame must be emitted before reload/background work starts; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
|
||||
panel.expect_background_task_pending("reload")?;
|
||||
let events = panel.events()?;
|
||||
let reload_started_index = events
|
||||
.iter()
|
||||
.position(|event| {
|
||||
event.event == "background_task_started"
|
||||
&& event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload")
|
||||
})
|
||||
.expect("held reload should start after first visible frame");
|
||||
assert!(
|
||||
ready_index < reload_started_index,
|
||||
"first visible frame and reload ordering should remain separate; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
panel.assert_fixture_ticket_row_not_rendered(&ready_ticket, Duration::from_millis(150))?;
|
||||
|
||||
panel.press(KeyPress::CtrlC)?;
|
||||
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
||||
assert!(status.success(), "panel should exit cleanly with Ctrl+C");
|
||||
drop(panel);
|
||||
assert_fixture_cleanup(fixture.cleanup()?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_dashboard_content_ready_has_startup_budget() -> yoi_e2e::Result<()> {
|
||||
let binary = yoi_binary()?;
|
||||
let fixture = FixtureWorkspace::new(&binary)?;
|
||||
assert_fixture_paths_are_isolated(&fixture);
|
||||
let expected_content = fixture.expected_dashboard_content();
|
||||
assert_backend_fixture_is_isolated(&fixture);
|
||||
let ready_ticket_id = fixture.ready_ticket_id.clone();
|
||||
let planning_ticket_id = fixture.planning_ticket_id.clone();
|
||||
let (_, workspace_id) = fixture
|
||||
.backend_target()
|
||||
.expect("panel fixture should own a Backend target");
|
||||
let workspace_id = workspace_id.to_string();
|
||||
|
||||
let started = Instant::now();
|
||||
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
|
||||
let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET
|
||||
.checked_sub(started.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_millis(0));
|
||||
panel.wait_for_first_visible_frame(first_visible_remaining)?;
|
||||
panel.wait_for_first_visible_frame(FIRST_VISIBLE_RENDER_BUDGET)?;
|
||||
let first_visible_elapsed = started.elapsed();
|
||||
eprintln!(
|
||||
"panel first visible frame: {first_visible_elapsed:?} (budget {FIRST_VISIBLE_RENDER_BUDGET:?}); artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
assert!(
|
||||
first_visible_elapsed <= FIRST_VISIBLE_RENDER_BUDGET,
|
||||
"first visible frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}",
|
||||
"first visible Backend panel frame took {first_visible_elapsed:?}, budget {FIRST_VISIBLE_RENDER_BUDGET:?}; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
|
||||
let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET
|
||||
.checked_sub(started.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_millis(0));
|
||||
let content_ready =
|
||||
panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?;
|
||||
assert!(
|
||||
content_ready.snapshot.header.ticket_configured,
|
||||
"dashboard content ready must include usable Ticket configuration; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
assert!(
|
||||
content_ready.snapshot.header.companion.is_some()
|
||||
&& content_ready.snapshot.header.orchestrator.is_some(),
|
||||
"dashboard content ready must include Companion and Orchestrator header status; got {:?}; artifacts at {}",
|
||||
content_ready.snapshot.header,
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
let ready = panel.wait_for(
|
||||
"Backend dashboard content ready with fixture Tickets",
|
||||
DASHBOARD_CONTENT_READY_BUDGET,
|
||||
|event| {
|
||||
event.event == "backend_dashboard_content_ready"
|
||||
&& event.data.get("workspace_id").and_then(Value::as_str)
|
||||
== Some(workspace_id.as_str())
|
||||
&& has_backend_ticket(
|
||||
&event.data,
|
||||
&ready_ticket_id,
|
||||
"Planning E2E Ticket A",
|
||||
"planning",
|
||||
)
|
||||
&& has_backend_ticket(
|
||||
&event.data,
|
||||
&planning_ticket_id,
|
||||
"Planning E2E Ticket B",
|
||||
"planning",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
assert_eq!(
|
||||
content_ready.snapshot_for_expected(&expected_content),
|
||||
expected_content.snapshot(),
|
||||
"dashboard content ready must match expected Ticket/action/overlay/header snapshot; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
ready.data.get("focus").and_then(Value::as_str),
|
||||
Some("tickets")
|
||||
);
|
||||
assert!(
|
||||
content_ready.categories.ready_ticket_rows > 0
|
||||
&& content_ready.categories.planning_ticket_rows > 0
|
||||
&& content_ready.categories.worker_rows > 0,
|
||||
"dashboard content ready must include ready Ticket, planning Ticket, and Worker categories; got {:?}; artifacts at {}",
|
||||
content_ready.categories,
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
let content_ready_elapsed = started.elapsed();
|
||||
eprintln!(
|
||||
"panel dashboard content ready: {content_ready_elapsed:?} (budget {DASHBOARD_CONTENT_READY_BUDGET:?}; first frame {first_visible_elapsed:?}); artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
assert!(
|
||||
content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET,
|
||||
"dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}",
|
||||
started.elapsed() <= DASHBOARD_CONTENT_READY_BUDGET,
|
||||
"Backend dashboard content missed startup budget; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
|
||||
let source_breakdown = panel.expect_dashboard_source_breakdown()?;
|
||||
assert!(
|
||||
source_breakdown.has_source("pod_metadata_status_probe.initial")
|
||||
&& source_breakdown.has_source("ticket_config_probe")
|
||||
&& source_breakdown.has_source("local_claim_scan")
|
||||
&& source_breakdown.has_source("ticket_scan_parse")
|
||||
&& source_breakdown.has_source("orchestration_overlay_validation_read_git")
|
||||
&& source_breakdown.has_source("workspace_panel.build.total"),
|
||||
"dashboard source breakdown should include worker metadata/status, ticket scan/parse, overlay validation/read/git, local claim scan, and panel-build sources; got {:?}; artifacts at {}",
|
||||
source_breakdown,
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
eprintln!(
|
||||
"panel dashboard source breakdown: {:?}; artifacts at {}",
|
||||
source_breakdown,
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
panel.press(KeyPress::Text("j".to_string()))?;
|
||||
panel.wait_for(
|
||||
"Backend dashboard Ticket selection change",
|
||||
Duration::from_secs(2),
|
||||
|event| {
|
||||
event.event == "backend_dashboard_selection_changed"
|
||||
&& event.data.get("selected_ticket_id").and_then(Value::as_str)
|
||||
== Some(planning_ticket_id.as_str())
|
||||
},
|
||||
)?;
|
||||
|
||||
panel.press(KeyPress::Text("r".to_string()))?;
|
||||
panel.wait_for(
|
||||
"Backend dashboard reload",
|
||||
Duration::from_secs(2),
|
||||
|event| {
|
||||
event.event == "backend_dashboard_reloaded"
|
||||
&& has_backend_ticket(
|
||||
&event.data,
|
||||
&ready_ticket_id,
|
||||
"Planning E2E Ticket A",
|
||||
"planning",
|
||||
)
|
||||
&& has_backend_ticket(
|
||||
&event.data,
|
||||
&planning_ticket_id,
|
||||
"Planning E2E Ticket B",
|
||||
"planning",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
panel.press(KeyPress::CtrlC)?;
|
||||
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
||||
assert!(status.success(), "panel should exit cleanly with Ctrl+C");
|
||||
assert!(
|
||||
status.success(),
|
||||
"Backend panel should exit cleanly with Ctrl+C"
|
||||
);
|
||||
assert!(
|
||||
panel
|
||||
.events()?
|
||||
.iter()
|
||||
.any(|event| event.event == "quit_requested")
|
||||
);
|
||||
drop(panel);
|
||||
assert_fixture_cleanup(fixture.cleanup()?);
|
||||
|
||||
let report = fixture.cleanup()?;
|
||||
assert_fixture_cleanup(&report);
|
||||
assert!(
|
||||
!report
|
||||
.snapshot_dir
|
||||
.join("config/yoi/backend-tokens.json")
|
||||
.exists(),
|
||||
"Backend credential must be removed before fixture snapshot"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_dashboard_content_ready_from_shell_enter_path() -> yoi_e2e::Result<()> {
|
||||
fn backend_panel_shell_enter_path_uses_the_same_backend_authority() -> yoi_e2e::Result<()> {
|
||||
let binary = yoi_binary()?;
|
||||
let fixture = FixtureWorkspace::new(&binary)?;
|
||||
assert_fixture_paths_are_isolated(&fixture);
|
||||
let expected_content = fixture.expected_dashboard_content();
|
||||
assert_backend_fixture_is_isolated(&fixture);
|
||||
let ready_ticket_id = fixture.ready_ticket_id.clone();
|
||||
|
||||
let (mut panel, started) = PanelHarness::spawn_via_shell_enter(fixture.panel_config(binary))?;
|
||||
let first_visible_remaining = FIRST_VISIBLE_RENDER_BUDGET
|
||||
.checked_sub(started.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_millis(0));
|
||||
panel.wait_for_first_visible_frame(first_visible_remaining)?;
|
||||
let first_visible_elapsed = started.elapsed();
|
||||
|
||||
let content_ready_remaining = DASHBOARD_CONTENT_READY_BUDGET
|
||||
.checked_sub(started.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_millis(0));
|
||||
let content_ready =
|
||||
panel.wait_for_dashboard_content_ready(&expected_content, content_ready_remaining)?;
|
||||
assert_eq!(
|
||||
content_ready.snapshot_for_expected(&expected_content),
|
||||
expected_content.snapshot(),
|
||||
"shell-enter dashboard content ready must match expected Ticket/action/overlay/header snapshot; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
let content_ready_elapsed = started.elapsed();
|
||||
eprintln!(
|
||||
"panel shell-enter dashboard content ready: {content_ready_elapsed:?} (budget {DASHBOARD_CONTENT_READY_BUDGET:?}; first frame {first_visible_elapsed:?}); artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
panel.wait_for_first_visible_frame(FIRST_VISIBLE_RENDER_BUDGET)?;
|
||||
panel.wait_for(
|
||||
"shell-enter Backend dashboard content",
|
||||
DASHBOARD_CONTENT_READY_BUDGET,
|
||||
|event| {
|
||||
event.event == "backend_dashboard_content_ready"
|
||||
&& has_backend_ticket(
|
||||
&event.data,
|
||||
&ready_ticket_id,
|
||||
"Planning E2E Ticket A",
|
||||
"planning",
|
||||
)
|
||||
},
|
||||
)?;
|
||||
assert!(
|
||||
content_ready_elapsed <= DASHBOARD_CONTENT_READY_BUDGET,
|
||||
"shell-enter dashboard content ready took {content_ready_elapsed:?}, budget {DASHBOARD_CONTENT_READY_BUDGET:?}; artifacts at {}",
|
||||
started.elapsed() <= DASHBOARD_CONTENT_READY_BUDGET,
|
||||
"shell-enter Backend dashboard content missed startup budget; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
|
||||
let source_breakdown = panel.expect_dashboard_source_breakdown()?;
|
||||
assert!(
|
||||
source_breakdown.has_source("workspace_panel.build.total"),
|
||||
"shell-enter dashboard source breakdown should include total panel build source; got {:?}; artifacts at {}",
|
||||
source_breakdown,
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
|
||||
panel.press(KeyPress::CtrlC)?;
|
||||
panel.press(KeyPress::Esc)?;
|
||||
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
||||
assert!(status.success(), "panel should exit cleanly with Ctrl+C");
|
||||
assert!(
|
||||
status.success(),
|
||||
"Backend panel should exit cleanly with Escape"
|
||||
);
|
||||
drop(panel);
|
||||
assert_fixture_cleanup(fixture.cleanup()?);
|
||||
let report = fixture.cleanup()?;
|
||||
assert_fixture_cleanup(&report);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> {
|
||||
let binary = yoi_binary()?;
|
||||
let fixture = FixtureWorkspace::new(&binary)?;
|
||||
assert_fixture_paths_are_isolated(&fixture);
|
||||
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
|
||||
|
||||
panel.expect_mouse_capture_enabled()?;
|
||||
panel.assert_no_full_drag_mouse_capture()?;
|
||||
let rows = panel.wait_for_rows(2)?;
|
||||
assert_no_runtime_or_host_pod_leak(
|
||||
&fixture,
|
||||
&rows.rows,
|
||||
panel.artifacts().dir.display().to_string().as_str(),
|
||||
);
|
||||
let selected = rows.selected.clone();
|
||||
let target = rows
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| Some(&row.key) != selected.as_ref())
|
||||
.cloned()
|
||||
.expect("fixture should render a second selectable row");
|
||||
|
||||
let before_events = panel.events()?.len();
|
||||
panel.click(&target)?;
|
||||
panel.expect_selection(&target.key)?;
|
||||
|
||||
let events = panel.events()?;
|
||||
assert!(
|
||||
events[before_events..]
|
||||
.iter()
|
||||
.all(|event| event.event != "action_requested"),
|
||||
"mouse selection must not dispatch panel actions; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
panel.assert_no_full_drag_mouse_capture()?;
|
||||
|
||||
panel.press(KeyPress::CtrlC)?;
|
||||
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
||||
assert!(status.success(), "panel should exit cleanly with Ctrl+C");
|
||||
drop(panel);
|
||||
assert_fixture_cleanup(fixture.cleanup()?);
|
||||
Ok(())
|
||||
fn has_backend_ticket(data: &Value, id: &str, title: &str, workflow_state: &str) -> bool {
|
||||
data.get("tickets")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|tickets| {
|
||||
tickets.iter().any(|ticket| {
|
||||
ticket.get("id").and_then(Value::as_str) == Some(id)
|
||||
&& ticket.get("title").and_then(Value::as_str) == Some(title)
|
||||
&& ticket.get("workflow_state").and_then(Value::as_str) == Some(workflow_state)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_mouse_wheel_moves_selection_without_full_drag_capture() -> yoi_e2e::Result<()> {
|
||||
let binary = yoi_binary()?;
|
||||
let fixture = FixtureWorkspace::new(&binary)?;
|
||||
assert_fixture_paths_are_isolated(&fixture);
|
||||
let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?;
|
||||
|
||||
panel.expect_mouse_capture_enabled()?;
|
||||
panel.assert_no_full_drag_mouse_capture()?;
|
||||
let rows = panel.wait_for_rows(2)?;
|
||||
assert_no_runtime_or_host_pod_leak(
|
||||
&fixture,
|
||||
&rows.rows,
|
||||
panel.artifacts().dir.display().to_string().as_str(),
|
||||
);
|
||||
let selected = rows
|
||||
.selected
|
||||
.as_ref()
|
||||
.expect("fixture should render an initially selected row")
|
||||
.clone();
|
||||
let selected_index = rows
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.key == selected)
|
||||
.expect("selected row should be rendered");
|
||||
let target_index = (selected_index + 1).min(rows.rows.len() - 1);
|
||||
assert_ne!(
|
||||
selected_index, target_index,
|
||||
"fixture should render a wheel-selectable next row"
|
||||
);
|
||||
let source = rows.rows[selected_index].clone();
|
||||
let target = rows.rows[target_index].clone();
|
||||
|
||||
let before_events = panel.events()?.len();
|
||||
panel.wheel_down(&source)?;
|
||||
panel.expect_selection(&target.key)?;
|
||||
panel.assert_no_full_drag_mouse_capture()?;
|
||||
|
||||
let events = panel.events()?;
|
||||
assert!(
|
||||
events[before_events..]
|
||||
.iter()
|
||||
.any(|event| event.event == "mouse_wheel"),
|
||||
"wheel movement should be visible in e2e events; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
assert!(
|
||||
events[before_events..]
|
||||
.iter()
|
||||
.all(|event| event.event != "action_requested"),
|
||||
"wheel selection must not dispatch panel actions; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
|
||||
panel.press(KeyPress::CtrlC)?;
|
||||
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
||||
assert!(status.success(), "panel should exit cleanly with Ctrl+C");
|
||||
drop(panel);
|
||||
assert_fixture_cleanup(fixture.cleanup()?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_ctrl_c_exits_promptly_after_background_barrier() -> yoi_e2e::Result<()> {
|
||||
let binary = yoi_binary()?;
|
||||
let fixture = FixtureWorkspace::new(&binary)?;
|
||||
assert_fixture_paths_are_isolated(&fixture);
|
||||
let mut panel =
|
||||
PanelHarness::spawn(fixture.panel_config_holding_background_task(binary, "reload"))?;
|
||||
|
||||
panel.wait_for("panel_ready", Duration::from_secs(5), |event| {
|
||||
event.event == "panel_ready"
|
||||
})?;
|
||||
panel.expect_background_task_pending("reload")?;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
panel.press(KeyPress::CtrlC)?;
|
||||
let status = panel.expect_exit_within(PanelHarness::default_exit_wait())?;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(status.success(), "panel should exit cleanly with Ctrl+C");
|
||||
assert!(
|
||||
elapsed <= PanelHarness::default_exit_wait(),
|
||||
"quit latency {elapsed:?} exceeded threshold; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
let events = panel.events()?;
|
||||
assert!(
|
||||
events.iter().any(|event| event.event == "quit_requested"),
|
||||
"quit_requested observability event missing; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
assert!(
|
||||
events.iter().any(|event| {
|
||||
event.event == "background_task_aborted"
|
||||
&& event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload")
|
||||
}),
|
||||
"pending reload task should be aborted before quit completes; artifacts at {}",
|
||||
panel.artifacts().dir.display()
|
||||
);
|
||||
drop(panel);
|
||||
assert_fixture_cleanup(fixture.cleanup()?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_fixture_paths_are_isolated(fixture: &FixtureWorkspace) {
|
||||
assert!(
|
||||
fixture.root.exists(),
|
||||
"fixture temp root should exist during scenario"
|
||||
);
|
||||
fn assert_backend_fixture_is_isolated(fixture: &FixtureWorkspace) {
|
||||
assert!(fixture.root.exists());
|
||||
assert!(fixture.workspace.starts_with(&fixture.root));
|
||||
assert!(fixture.home.starts_with(&fixture.root));
|
||||
assert!(fixture.xdg_data_home.starts_with(&fixture.root));
|
||||
assert!(fixture.xdg_state_home.starts_with(&fixture.root));
|
||||
assert!(fixture.xdg_config_home.starts_with(&fixture.root));
|
||||
assert!(fixture.xdg_runtime_dir.starts_with(&fixture.root));
|
||||
assert!(
|
||||
!fixture.artifacts_dir.starts_with(&fixture.root),
|
||||
"persistent artifacts must live outside the temp root so cleanup can remove the fixture"
|
||||
);
|
||||
if let Some(host_runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
assert_ne!(
|
||||
fixture.xdg_runtime_dir,
|
||||
std::path::PathBuf::from(host_runtime),
|
||||
"tested yoi must not reuse host XDG_RUNTIME_DIR"
|
||||
);
|
||||
}
|
||||
assert!(!fixture.workspace.join(".yoi").exists());
|
||||
assert!(fixture.backend_target().is_some());
|
||||
}
|
||||
|
||||
fn assert_no_runtime_or_host_pod_leak(
|
||||
fixture: &FixtureWorkspace,
|
||||
rows: &[RenderedPanelRow],
|
||||
artifacts: &str,
|
||||
) {
|
||||
let rendered = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
row.key.kind,
|
||||
row.key.id,
|
||||
row.title,
|
||||
row.status.as_deref().unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.collect::<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) {
|
||||
fn assert_fixture_cleanup(report: &FixtureCleanupReport) {
|
||||
assert!(
|
||||
report.cleanup_success,
|
||||
"fixture cleanup failed; report at {}: {:?}",
|
||||
report.report_path.display(),
|
||||
report.cleanup_error
|
||||
);
|
||||
assert!(
|
||||
!report.fixture_root.exists(),
|
||||
"fixture temp root should be removed after scenario: {}",
|
||||
report.fixture_root.display()
|
||||
);
|
||||
assert!(
|
||||
report.report_path.exists(),
|
||||
"cleanup artifact should persist"
|
||||
);
|
||||
assert!(
|
||||
report.snapshot_dir.exists(),
|
||||
"fixture snapshot should persist under target/e2e-artifacts before temp cleanup"
|
||||
);
|
||||
assert!(!report.fixture_root.exists());
|
||||
assert!(report.report_path.exists());
|
||||
assert!(report.snapshot_dir.exists());
|
||||
let snapshot = fs::read_to_string(report.snapshot_dir.join("../fixture.json")).unwrap();
|
||||
assert!(!snapshot.contains("yoi_api_"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user