yoi/tests/e2e/src/lib.rs

2136 lines
69 KiB
Rust

//! Opt-in E2E helpers for driving the real `yoi panel` process through a PTY.
//!
//! The harness intentionally sends keyboard and mouse input only through the PTY.
//! Structured JSONL events emitted by the TUI are used for synchronization,
//! assertions, and failure artifacts; they are not an input or authority channel.
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::os::fd::{AsRawFd, FromRawFd};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tempfile::TempDir;
const DEFAULT_WAIT: Duration = Duration::from_secs(5);
const DEFAULT_EXIT_WAIT: Duration = Duration::from_millis(1500);
static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
pub type Result<T> = std::result::Result<T, HarnessError>;
#[derive(Clone, Debug, Serialize)]
pub struct BinaryProviderInfo {
pub provider: String,
pub binary: PathBuf,
pub workspace_root: PathBuf,
pub cargo: Option<PathBuf>,
pub build_args: Vec<String>,
pub build_command: Option<String>,
pub profile: String,
pub tested_yoi_subprocess_env: TestedYoiEnvPolicy,
}
#[derive(Clone, Debug, Serialize)]
pub struct EnvPolicy {
pub env_clear: bool,
pub allowlist: Vec<String>,
pub path_allowed: bool,
pub provider_credentials_default_deny: Vec<String>,
pub secret_patterns_default_deny: Vec<String>,
pub note: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct TestedYoiEnvPolicy {
pub fixture_setup: EnvPolicy,
pub panel: EnvPolicy,
}
impl BinaryProviderInfo {
fn log(&self) {
match &self.build_command {
Some(command) => eprintln!(
"yoi-e2e binary provider={} command={} binary={}",
self.provider,
command,
self.binary.display()
),
None => eprintln!(
"yoi-e2e binary provider={} binary={}",
self.provider,
self.binary.display()
),
}
}
}
fn env_policy(allowlist: &[&str], note: &str) -> EnvPolicy {
EnvPolicy {
env_clear: true,
allowlist: allowlist.iter().map(|name| (*name).to_owned()).collect(),
path_allowed: false,
provider_credentials_default_deny: vec![
"OPENAI_API_KEY".to_owned(),
"ANTHROPIC_API_KEY".to_owned(),
"GEMINI_API_KEY".to_owned(),
],
secret_patterns_default_deny: vec![
"*_API_KEY".to_owned(),
"*_TOKEN".to_owned(),
"*_SECRET".to_owned(),
"*_CREDENTIAL*".to_owned(),
],
note: note.to_owned(),
}
}
fn fixture_setup_env_policy() -> EnvPolicy {
env_policy(
&[
"HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"YOI_POD_RUNTIME_COMMAND",
],
"tested yoi fixture setup commands use env_clear and receive only fixture HOME, XDG data/state/config/runtime dirs, and the explicit runtime binary override",
)
}
fn tui_env_policy(include_hold_background_task: bool, include_rewind_fixture: bool) -> EnvPolicy {
let mut allowlist = vec![
"HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"TERM",
"YOI_TUI_TEST_EVENTS",
"YOI_POD_RUNTIME_COMMAND",
];
if include_hold_background_task {
allowlist.push("YOI_TUI_TEST_HOLD_BACKGROUND_TASK");
}
if include_rewind_fixture {
allowlist.push("YOI_TUI_TEST_REWIND_FIXTURE");
}
env_policy(
&allowlist,
"tested yoi TUI subprocess uses env_clear and receives only fixture HOME, XDG data/state/config/runtime dirs, terminal/test-observer variables, explicit e2e fixture toggles, and the explicit runtime binary override",
)
}
fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy {
tui_env_policy(include_hold_background_task, false)
}
fn tested_yoi_env_policy_overview() -> TestedYoiEnvPolicy {
TestedYoiEnvPolicy {
fixture_setup: fixture_setup_env_policy(),
panel: panel_env_policy(true),
}
}
#[derive(Debug)]
pub enum HarnessError {
Io(io::Error),
Json(serde_json::Error),
CommandFailed {
program: PathBuf,
args: Vec<String>,
status: ExitStatus,
stdout: String,
stderr: String,
},
Timeout {
what: String,
artifacts: PanelArtifacts,
},
MissingBinary(PathBuf),
MouseCaptureNotEnabled {
artifacts: PanelArtifacts,
},
FullDragMouseCaptureEnabled {
artifacts: PanelArtifacts,
},
Protocol(String),
}
impl std::fmt::Display for HarnessError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(err) => write!(f, "io error: {err}"),
Self::Json(err) => write!(f, "json error: {err}"),
Self::CommandFailed {
program,
args,
status,
stdout,
stderr,
} => write!(
f,
"{} exited with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
command_display(program, args)
),
Self::Timeout { what, artifacts } => write!(
f,
"timed out waiting for {what}; artifacts at {}",
artifacts.dir.display()
),
Self::MissingBinary(path) => write!(
f,
"missing yoi binary {}; set YOI_E2E_BIN to an existing binary or inspect target/e2e-artifacts/binary-provider.json",
path.display()
),
Self::MouseCaptureNotEnabled { artifacts } => write!(
f,
"terminal mouse capture was not observed before mouse input; artifacts at {}",
artifacts.dir.display()
),
Self::FullDragMouseCaptureEnabled { artifacts } => write!(
f,
"forbidden full drag-motion mouse capture (?1002h/?1003h) was observed; artifacts at {}",
artifacts.dir.display()
),
Self::Protocol(message) => write!(f, "protocol error: {message}"),
}
}
}
impl std::error::Error for HarnessError {}
impl From<io::Error> for HarnessError {
fn from(value: io::Error) -> Self {
Self::Io(value)
}
}
impl From<serde_json::Error> for HarnessError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
}
}
#[derive(Debug, Clone)]
pub struct PanelHarnessConfig {
pub binary: PathBuf,
pub workspace: PathBuf,
pub home: PathBuf,
pub xdg_data_home: PathBuf,
pub xdg_state_home: PathBuf,
pub xdg_config_home: PathBuf,
pub xdg_runtime_dir: PathBuf,
pub fixture_root: PathBuf,
pub terminal_size: (u16, u16),
pub hold_background_task: Option<String>,
pub rewind_fixture: bool,
pub command_args: Vec<String>,
pub artifacts_dir: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarnessEvent {
pub ts_ms: u128,
pub surface: String,
pub event: String,
#[serde(default)]
pub data: Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PanelRowKey {
pub kind: String,
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelRect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderedPanelRow {
pub key: PanelRowKey,
pub title: String,
pub status: Option<String>,
pub action: Option<String>,
#[serde(default)]
pub disabled_reason: Option<String>,
#[serde(default)]
pub local_state: Option<String>,
#[serde(default)]
pub overlay_state: Option<String>,
#[serde(default)]
pub overlay_detail: Option<String>,
pub rect: PanelRect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpectedPanelTicketRow {
pub id: String,
pub title: String,
pub status: String,
pub action: Option<String>,
pub disabled_reason: Option<String>,
pub local_state: Option<String>,
pub overlay_state: Option<String>,
}
impl ExpectedPanelTicketRow {
pub fn new(id: impl Into<String>, title: impl Into<String>, status: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
status: status.into(),
action: None,
disabled_reason: None,
local_state: None,
overlay_state: None,
}
}
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into());
self
}
pub fn with_disabled_reason(mut self, disabled_reason: impl Into<String>) -> Self {
self.disabled_reason = Some(disabled_reason.into());
self
}
pub fn with_local_state(mut self, local_state: impl Into<String>) -> Self {
self.local_state = Some(local_state.into());
self
}
pub fn with_overlay_state(mut self, overlay_state: impl Into<String>) -> Self {
self.overlay_state = Some(overlay_state.into());
self
}
pub fn matches(&self, row: &RenderedPanelRow) -> bool {
row.key.kind == "ticket"
&& row.key.id == self.id
&& row.title == self.title
&& row.status.as_deref() == Some(self.status.as_str())
&& self.action.as_ref().map_or(true, |action| {
row.action.as_deref() == Some(action.as_str())
})
&& self.disabled_reason.as_ref().map_or(true, |reason| {
row.disabled_reason
.as_deref()
.is_some_and(|actual| actual.contains(reason))
})
&& self.local_state.as_ref().map_or(true, |state| {
row.local_state.as_deref() == Some(state.as_str())
})
&& self.overlay_state.as_ref().map_or(true, |state| {
row.overlay_state.as_deref() == Some(state.as_str())
})
}
fn description(&self) -> String {
format!(
"ticket row id={} title={:?} status={} action={:?} disabled_reason={:?} local_state={:?} overlay_state={:?}",
self.id,
self.title,
self.status,
self.action,
self.disabled_reason,
self.local_state,
self.overlay_state,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpectedDashboardContent {
pub tickets: Vec<ExpectedPanelTicketRow>,
pub pod_names: Vec<String>,
pub companion_status: String,
pub orchestrator_status: String,
}
impl ExpectedDashboardContent {
pub fn snapshot(&self) -> DashboardContentSnapshot {
DashboardContentSnapshot {
tickets: self.tickets.clone(),
pod_names: self.pod_names.clone(),
companion_status: self.companion_status.clone(),
orchestrator_status: self.orchestrator_status.clone(),
}
}
fn description(&self) -> String {
let tickets = self
.tickets
.iter()
.map(ExpectedPanelTicketRow::description)
.collect::<Vec<_>>()
.join(", ");
let pods = self.pod_names.join(", ");
format!(
"tickets=[{tickets}] pods=[{pods}] companion={} orchestrator={}",
self.companion_status, self.orchestrator_status
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardContentSnapshot {
pub tickets: Vec<ExpectedPanelTicketRow>,
pub pod_names: Vec<String>,
pub companion_status: String,
pub orchestrator_status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardContentReady {
pub snapshot: DashboardSnapshot,
pub categories: DashboardContentCategories,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardSnapshot {
pub header: DashboardHeader,
pub rows: Vec<RenderedPanelRow>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardHeader {
pub ticket_configured: bool,
pub companion: Option<DashboardCompanionState>,
pub orchestrator: Option<DashboardOrchestratorState>,
#[serde(default)]
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardCompanionState {
pub pod_name: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardOrchestratorState {
pub pod_name: String,
pub status: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardContentCategories {
pub ticket_rows: usize,
pub ready_ticket_rows: usize,
pub planning_ticket_rows: usize,
pub pod_rows: usize,
pub actionable_rows: usize,
}
impl DashboardContentReady {
pub fn rows_rendered(&self) -> RowsRendered {
RowsRendered {
selected: None,
rows: self.snapshot.rows.clone(),
}
}
pub fn snapshot_for_expected(
&self,
expected: &ExpectedDashboardContent,
) -> DashboardContentSnapshot {
DashboardContentSnapshot {
tickets: expected
.tickets
.iter()
.filter(|ticket| self.snapshot.rows.iter().any(|row| ticket.matches(row)))
.cloned()
.collect(),
pod_names: expected
.pod_names
.iter()
.filter(|pod_name| {
self.snapshot
.rows
.iter()
.any(|row| row.key.kind == "pod" && row.key.id == pod_name.as_str())
})
.cloned()
.collect(),
companion_status: self
.snapshot
.header
.companion
.as_ref()
.map(|companion| companion.status.clone())
.unwrap_or_default(),
orchestrator_status: self
.snapshot
.header
.orchestrator
.as_ref()
.map(|orchestrator| orchestrator.status.clone())
.unwrap_or_default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardSourceBreakdown {
pub total_elapsed_ms: u128,
pub sources: Vec<DashboardSourceTiming>,
pub ticket_rows: usize,
pub pod_rows: usize,
pub diagnostics: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardSourceTiming {
pub source: String,
pub elapsed_ms: u128,
}
impl DashboardSourceBreakdown {
pub fn has_source(&self, source: &str) -> bool {
self.sources.iter().any(|timing| timing.source == source)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RowsRendered {
pub selected: Option<PanelRowKey>,
pub rows: Vec<RenderedPanelRow>,
}
impl RowsRendered {
pub fn fixture_ticket_row(
&self,
expected: &ExpectedPanelTicketRow,
) -> Option<&RenderedPanelRow> {
self.rows.iter().find(|row| expected.matches(row))
}
pub fn has_fixture_ticket_row(&self, expected: &ExpectedPanelTicketRow) -> bool {
self.fixture_ticket_row(expected).is_some()
}
}
fn rows_rendered_event_has_fixture_ticket(
event: &HarnessEvent,
expected: &ExpectedPanelTicketRow,
) -> bool {
serde_json::from_value::<RowsRendered>(event.data.clone())
.map(|rows| rows.has_fixture_ticket_row(expected))
.unwrap_or(false)
}
fn describe_rows(rows: &RowsRendered) -> String {
rows.rows
.iter()
.map(|row| {
format!(
"{}:{} title={:?} status={:?}",
row.key.kind, row.key.id, row.title, row.status
)
})
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, Clone)]
pub enum KeyPress {
CtrlC,
CtrlD,
CtrlR,
Enter,
Esc,
Text(String),
}
#[derive(Debug, Clone)]
pub struct PanelArtifacts {
pub dir: PathBuf,
pub events_jsonl: PathBuf,
pub input_log: PathBuf,
pub output_log: PathBuf,
pub run_json: PathBuf,
}
pub struct PanelHarness {
child: Child,
master: File,
reader: Option<JoinHandle<()>>,
output: Arc<Mutex<Vec<u8>>>,
last_event_offset: usize,
artifacts: PanelArtifacts,
}
impl PanelHarness {
pub fn spawn(config: PanelHarnessConfig) -> Result<Self> {
if !config.binary.exists() {
return Err(HarnessError::MissingBinary(config.binary));
}
fs::create_dir_all(&config.artifacts_dir)?;
let artifacts = PanelArtifacts {
dir: config.artifacts_dir.clone(),
events_jsonl: config.artifacts_dir.join("events.jsonl"),
input_log: config.artifacts_dir.join("input.log"),
output_log: config.artifacts_dir.join("pty-output.log"),
run_json: config.artifacts_dir.join("run.json"),
};
fs::write(&artifacts.events_jsonl, "")?;
fs::write(&artifacts.input_log, "")?;
fs::write(&artifacts.output_log, "")?;
let env_policy =
tui_env_policy(config.hold_background_task.is_some(), config.rewind_fixture);
fs::write(
&artifacts.run_json,
serde_json::to_vec_pretty(&serde_json::json!({
"binary": config.binary,
"args": &config.command_args,
"workspace": config.workspace,
"home": config.home,
"xdg_data_home": config.xdg_data_home,
"xdg_state_home": config.xdg_state_home,
"xdg_config_home": config.xdg_config_home,
"xdg_runtime_dir": config.xdg_runtime_dir,
"fixture_root": config.fixture_root,
"launch_mode": "direct_exec",
"runtime_policy": {
"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"
},
"terminal_size": {
"columns": config.terminal_size.0,
"rows": config.terminal_size.1,
},
"hold_background_task": config.hold_background_task,
"rewind_fixture": config.rewind_fixture,
"tested_yoi_env_policy": &env_policy,
}))?,
)?;
let (master, slave) = open_pty(config.terminal_size)?;
let slave_for_stdin = slave.try_clone()?;
let slave_for_stdout = slave.try_clone()?;
let mut command = Command::new(&config.binary);
command
.args(&config.command_args)
.env_clear()
.env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl)
.env("YOI_POD_RUNTIME_COMMAND", &config.binary)
.env("HOME", &config.home)
.env("XDG_DATA_HOME", &config.xdg_data_home)
.env("XDG_STATE_HOME", &config.xdg_state_home)
.env("XDG_CONFIG_HOME", &config.xdg_config_home)
.env("XDG_RUNTIME_DIR", &config.xdg_runtime_dir)
.env("TERM", "xterm-256color")
.stdin(Stdio::from(slave_for_stdin))
.stdout(Stdio::from(slave_for_stdout))
.stderr(Stdio::from(slave));
if let Some(task) = &config.hold_background_task {
command.env("YOI_TUI_TEST_HOLD_BACKGROUND_TASK", task);
}
if config.rewind_fixture {
command.env("YOI_TUI_TEST_REWIND_FIXTURE", "1");
}
let child = command.spawn()?;
let output = Arc::new(Mutex::new(Vec::new()));
let output_for_thread = Arc::clone(&output);
let mut reader_file = master.try_clone()?;
let output_log = artifacts.output_log.clone();
let reader = thread::spawn(move || {
let mut sink = OpenOptions::new()
.append(true)
.create(true)
.open(output_log)
.ok();
let mut buf = [0_u8; 4096];
loop {
match reader_file.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if let Some(sink) = sink.as_mut() {
let _ = sink.write_all(&buf[..n]);
}
if let Ok(mut output) = output_for_thread.lock() {
output.extend_from_slice(&buf[..n]);
}
}
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
});
Ok(Self {
child,
master,
reader: Some(reader),
output,
last_event_offset: 0,
artifacts,
})
}
pub fn spawn_via_shell_enter(config: PanelHarnessConfig) -> Result<(Self, Instant)> {
if !config.binary.exists() {
return Err(HarnessError::MissingBinary(config.binary));
}
fs::create_dir_all(&config.artifacts_dir)?;
let artifacts = PanelArtifacts {
dir: config.artifacts_dir.clone(),
events_jsonl: config.artifacts_dir.join("events.jsonl"),
input_log: config.artifacts_dir.join("input.log"),
output_log: config.artifacts_dir.join("pty-output.log"),
run_json: config.artifacts_dir.join("run.json"),
};
fs::write(&artifacts.events_jsonl, "")?;
fs::write(&artifacts.input_log, "")?;
fs::write(&artifacts.output_log, "")?;
let env_policy =
tui_env_policy(config.hold_background_task.is_some(), config.rewind_fixture);
fs::write(
&artifacts.run_json,
serde_json::to_vec_pretty(&serde_json::json!({
"binary": config.binary,
"args": &config.command_args,
"workspace": config.workspace,
"home": config.home,
"xdg_data_home": config.xdg_data_home,
"xdg_state_home": config.xdg_state_home,
"xdg_config_home": config.xdg_config_home,
"xdg_runtime_dir": config.xdg_runtime_dir,
"fixture_root": config.fixture_root,
"launch_mode": "shell_enter_exec",
"runtime_policy": {
"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"
},
"terminal_size": {
"columns": config.terminal_size.0,
"rows": config.terminal_size.1,
},
"hold_background_task": config.hold_background_task,
"rewind_fixture": config.rewind_fixture,
"tested_yoi_env_policy": &env_policy,
}))?,
)?;
let (master, slave) = open_pty(config.terminal_size)?;
let slave_for_stdin = slave.try_clone()?;
let slave_for_stdout = slave.try_clone()?;
let mut command = Command::new("/bin/sh");
command
.current_dir(&config.workspace)
.env_clear()
.env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl)
.env("YOI_POD_RUNTIME_COMMAND", &config.binary)
.env("HOME", &config.home)
.env("XDG_DATA_HOME", &config.xdg_data_home)
.env("XDG_STATE_HOME", &config.xdg_state_home)
.env("XDG_CONFIG_HOME", &config.xdg_config_home)
.env("XDG_RUNTIME_DIR", &config.xdg_runtime_dir)
.env("TERM", "xterm-256color")
.stdin(Stdio::from(slave_for_stdin))
.stdout(Stdio::from(slave_for_stdout))
.stderr(Stdio::from(slave));
if let Some(task) = &config.hold_background_task {
command.env("YOI_TUI_TEST_HOLD_BACKGROUND_TASK", task);
}
if config.rewind_fixture {
command.env("YOI_TUI_TEST_REWIND_FIXTURE", "1");
}
let child = command.spawn()?;
let output = Arc::new(Mutex::new(Vec::new()));
let output_for_thread = Arc::clone(&output);
let mut reader_file = master.try_clone()?;
let output_log = artifacts.output_log.clone();
let reader = thread::spawn(move || {
let mut sink = OpenOptions::new()
.append(true)
.create(true)
.open(output_log)
.ok();
let mut buf = [0_u8; 4096];
loop {
match reader_file.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if let Some(sink) = sink.as_mut() {
let _ = sink.write_all(&buf[..n]);
}
if let Ok(mut output) = output_for_thread.lock() {
output.extend_from_slice(&buf[..n]);
}
}
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
});
let mut harness = Self {
child,
master,
reader: Some(reader),
output,
last_event_offset: 0,
artifacts,
};
let command_line = shell_exec_command(&config.binary, &config.command_args);
let started = Instant::now();
harness.write_input(
"shell Enter yoi panel",
format!("{command_line}\n").as_bytes(),
)?;
Ok((harness, started))
}
pub fn wait_for<F>(
&mut self,
what: impl Into<String>,
timeout: Duration,
mut predicate: F,
) -> Result<HarnessEvent>
where
F: FnMut(&HarnessEvent) -> bool,
{
let what = what.into();
let start = Instant::now();
loop {
for event in self.read_new_events()? {
if predicate(&event) {
return Ok(event);
}
}
if let Some(status) = self.child.try_wait()? {
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"process exited with {status} before {what}"
)));
}
if start.elapsed() >= timeout {
self.flush_output_artifact()?;
return Err(HarnessError::Timeout {
what,
artifacts: self.artifacts.clone(),
});
}
thread::sleep(Duration::from_millis(20));
}
}
/// Waits for the legacy `panel_ready` observer event, which means the first
/// panel frame was painted. It intentionally does not mean workspace rows or
/// Ticket data are ready.
pub fn wait_for_first_visible_frame(&mut self, timeout: Duration) -> Result<HarnessEvent> {
self.wait_for(
"first visible panel frame (panel_ready, before rows readiness)",
timeout,
|event| event.event == "panel_ready",
)
}
/// Waits until a concrete fixture Ticket row has rendered. This is the
/// startup rows-ready signal; it validates id + title + state rather than
/// using only the number of rendered rows.
pub fn wait_for_fixture_ticket_rows_ready(
&mut self,
expected: &ExpectedPanelTicketRow,
timeout: Duration,
) -> Result<RowsRendered> {
let description = expected.description();
let event = self.wait_for(
format!("fixture Ticket rows ready ({description})"),
timeout,
|event| {
event.event == "rows_rendered"
&& rows_rendered_event_has_fixture_ticket(event, expected)
},
)?;
serde_json::from_value(event.data).map_err(HarnessError::from)
}
/// Waits for the dashboard-content-ready observer event. Unlike first-frame
/// or row-count readiness, this requires representative user-visible content:
/// ready + planning Ticket rows and a Pod row, then checks the fixture-specific
/// rows as a small snapshot of the expected dashboard content.
pub fn wait_for_dashboard_content_ready(
&mut self,
expected: &ExpectedDashboardContent,
timeout: Duration,
) -> Result<DashboardContentReady> {
let expected_snapshot = expected.snapshot();
let description = expected.description();
let event = self.wait_for(
format!("dashboard content ready ({description})"),
timeout,
|event| {
if event.event != "dashboard_content_ready" {
return false;
}
serde_json::from_value::<DashboardContentReady>(event.data.clone())
.map(|ready| ready.snapshot_for_expected(expected) == expected_snapshot)
.unwrap_or(false)
},
)?;
serde_json::from_value(event.data).map_err(HarnessError::from)
}
pub fn latest_dashboard_source_breakdown(
&mut self,
) -> Result<Option<DashboardSourceBreakdown>> {
Ok(self
.events()?
.into_iter()
.rev()
.filter(|event| event.event == "dashboard_source_breakdown")
.find_map(|event| serde_json::from_value(event.data).ok()))
}
pub fn expect_dashboard_source_breakdown(&mut self) -> Result<DashboardSourceBreakdown> {
self.latest_dashboard_source_breakdown()?.ok_or_else(|| {
HarnessError::Protocol("missing dashboard_source_breakdown observer event".to_string())
})
}
pub fn assert_fixture_ticket_row_not_rendered(
&mut self,
expected: &ExpectedPanelTicketRow,
duration: Duration,
) -> Result<()> {
let start = Instant::now();
while start.elapsed() < duration {
if let Some(rows) = self
.events()?
.iter()
.filter(|event| event.event == "rows_rendered")
.filter_map(|event| serde_json::from_value::<RowsRendered>(event.data.clone()).ok())
.find(|rows| rows.has_fixture_ticket_row(expected))
{
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"fixture Ticket row rendered before data-backed rows readiness was expected: {}; rows: {}",
expected.description(),
describe_rows(&rows)
)));
}
if let Some(status) = self.child.try_wait()? {
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"process exited with {status} while asserting fixture Ticket rows stayed delayed"
)));
}
thread::sleep(Duration::from_millis(20));
}
Ok(())
}
pub fn wait_for_rows(&mut self, min_rows: usize) -> Result<RowsRendered> {
let event = self.wait_for("rows_rendered", DEFAULT_WAIT, |event| {
event.event == "rows_rendered"
&& event
.data
.get("rows")
.and_then(Value::as_array)
.is_some_and(|rows| rows.len() >= min_rows)
})?;
serde_json::from_value(event.data).map_err(HarnessError::from)
}
pub fn expect_mouse_capture_enabled(&mut self) -> Result<()> {
let start = Instant::now();
loop {
if self.mouse_capture_enabled() {
return Ok(());
}
if start.elapsed() >= DEFAULT_WAIT {
self.flush_output_artifact()?;
return Err(HarnessError::MouseCaptureNotEnabled {
artifacts: self.artifacts.clone(),
});
}
if let Some(status) = self.child.try_wait()? {
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"process exited with {status} before mouse capture was enabled"
)));
}
thread::sleep(Duration::from_millis(20));
}
}
pub fn assert_no_full_drag_mouse_capture(&mut self) -> Result<()> {
if self.full_drag_mouse_capture_observed() {
self.flush_output_artifact()?;
return Err(HarnessError::FullDragMouseCaptureEnabled {
artifacts: self.artifacts.clone(),
});
}
Ok(())
}
pub fn expect_event(
&mut self,
event_name: &'static str,
timeout: Duration,
) -> Result<HarnessEvent> {
self.wait_for(event_name, timeout, |event| event.event == event_name)
}
pub fn count_events(&mut self, event_name: &str) -> Result<usize> {
Ok(self
.events()?
.into_iter()
.filter(|event| event.event == event_name)
.count())
}
pub fn wait_for_no_additional_events(
&mut self,
event_name: &str,
baseline: usize,
duration: Duration,
) -> Result<()> {
let start = Instant::now();
while start.elapsed() < duration {
if let Some(status) = self.child.try_wait()? {
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"process exited with {status} while waiting for no additional {event_name} events"
)));
}
let count = self.count_events(event_name)?;
if count > baseline {
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"observed {count} {event_name} events; expected no more than {baseline}"
)));
}
thread::sleep(Duration::from_millis(20));
}
Ok(())
}
pub fn expect_background_task_pending(&mut self, task: &str) -> Result<()> {
let start = Instant::now();
loop {
if background_task_is_pending(&self.events()?, task) {
return Ok(());
}
if start.elapsed() >= DEFAULT_WAIT {
self.flush_output_artifact()?;
return Err(HarnessError::Timeout {
what: format!("background task {task:?} pending"),
artifacts: self.artifacts.clone(),
});
}
if let Some(status) = self.child.try_wait()? {
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"process exited with {status} before background task {task:?} was pending"
)));
}
thread::sleep(Duration::from_millis(20));
}
}
pub fn click(&mut self, row: &RenderedPanelRow) -> Result<()> {
if !self.mouse_capture_enabled() {
self.flush_output_artifact()?;
return Err(HarnessError::MouseCaptureNotEnabled {
artifacts: self.artifacts.clone(),
});
}
let x = row.rect.x.saturating_add(1);
let y = row.rect.y;
self.write_input(
&format!("mouse click {} at {},{}", row.title, x, y),
format!("\u{1b}[<0;{};{}M", x.saturating_add(1), y.saturating_add(1)).as_bytes(),
)
}
pub fn wheel_down(&mut self, row: &RenderedPanelRow) -> Result<()> {
self.wheel(row, 65, "down")
}
pub fn wheel_up(&mut self, row: &RenderedPanelRow) -> Result<()> {
self.wheel(row, 64, "up")
}
pub fn wheel(&mut self, row: &RenderedPanelRow, sgr_button: u8, label: &str) -> Result<()> {
if !self.mouse_capture_enabled() {
self.flush_output_artifact()?;
return Err(HarnessError::MouseCaptureNotEnabled {
artifacts: self.artifacts.clone(),
});
}
let x = row.rect.x.saturating_add(1);
let y = row.rect.y;
self.write_input(
&format!("mouse wheel {label} {} at {},{}", row.title, x, y),
format!(
"\u{1b}[<{};{};{}M",
sgr_button,
x.saturating_add(1),
y.saturating_add(1)
)
.as_bytes(),
)
}
pub fn press(&mut self, key: KeyPress) -> Result<()> {
match key {
KeyPress::CtrlC => self.write_input("Ctrl+C", b"\x03"),
KeyPress::CtrlD => self.write_input("Ctrl+D", b"\x04"),
KeyPress::CtrlR => self.write_input("Ctrl+R", b"\x12"),
KeyPress::Enter => self.write_input("Enter", b"\r"),
KeyPress::Esc => self.write_input("Esc", b"\x1b"),
KeyPress::Text(text) => self.write_input(&format!("text {text:?}"), text.as_bytes()),
}
}
pub fn expect_selection(&mut self, expected: &PanelRowKey) -> Result<HarnessEvent> {
self.wait_for("selection_changed", DEFAULT_WAIT, |event| {
event.event == "selection_changed"
&& event.data.get("selected").is_some_and(|selected| {
serde_json::from_value::<PanelRowKey>(selected.clone())
.is_ok_and(|actual| actual == *expected)
})
})
}
pub fn expect_exit_within(&mut self, timeout: Duration) -> Result<ExitStatus> {
let start = Instant::now();
loop {
if let Some(status) = self.child.try_wait()? {
self.flush_output_artifact()?;
let _ = self.reader.take();
return Ok(status);
}
if start.elapsed() >= timeout {
self.flush_output_artifact()?;
return Err(HarnessError::Timeout {
what: format!("process exit within {timeout:?}"),
artifacts: self.artifacts.clone(),
});
}
thread::sleep(Duration::from_millis(10));
}
}
pub fn output_len(&self) -> usize {
self.output.lock().map(|output| output.len()).unwrap_or(0)
}
pub fn wait_for_output_contains_from(
&mut self,
start_offset: usize,
needle: &str,
timeout: Duration,
) -> Result<()> {
let start = Instant::now();
let needle = needle.as_bytes();
loop {
if self.output_after(start_offset, needle) {
return Ok(());
}
if let Some(status) = self.child.try_wait()? {
self.flush_output_artifact()?;
return Err(HarnessError::Protocol(format!(
"process exited with {status} before PTY output contained {:?}",
String::from_utf8_lossy(needle)
)));
}
if start.elapsed() >= timeout {
self.flush_output_artifact()?;
return Err(HarnessError::Timeout {
what: format!(
"PTY output containing {:?} after offset {start_offset}",
String::from_utf8_lossy(needle)
),
artifacts: self.artifacts.clone(),
});
}
thread::sleep(Duration::from_millis(20));
}
}
pub fn events(&mut self) -> Result<Vec<HarnessEvent>> {
let text = fs::read_to_string(&self.artifacts.events_jsonl)?;
text.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).map_err(HarnessError::from))
.collect()
}
pub fn artifacts(&self) -> &PanelArtifacts {
&self.artifacts
}
pub fn default_exit_wait() -> Duration {
DEFAULT_EXIT_WAIT
}
fn read_new_events(&mut self) -> Result<Vec<HarnessEvent>> {
let text = fs::read_to_string(&self.artifacts.events_jsonl)?;
let mut events = Vec::new();
let new_text = text.get(self.last_event_offset..).unwrap_or_default();
let mut consumed = self.last_event_offset;
for segment in new_text.split_inclusive('\n') {
if !segment.ends_with('\n') {
break;
}
consumed += segment.len();
let line = segment.trim();
if !line.is_empty() {
events.push(serde_json::from_str(line)?);
}
}
self.last_event_offset = consumed;
Ok(events)
}
fn write_input(&mut self, label: &str, bytes: &[u8]) -> Result<()> {
let mut log = OpenOptions::new()
.append(true)
.create(true)
.open(&self.artifacts.input_log)?;
writeln!(log, "{} {} bytes {label}", now_ms(), bytes.len())?;
self.master.write_all(bytes)?;
self.master.flush()?;
Ok(())
}
fn output_after(&self, start_offset: usize, needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
self.output
.lock()
.map(|output| {
let start = start_offset.min(output.len());
output[start..]
.windows(needle.len())
.any(|window| window == needle)
})
.unwrap_or(false)
}
fn mouse_capture_enabled(&self) -> bool {
self.output
.lock()
.map(|output| output_has_enabled_mouse_capture(&output))
.unwrap_or(false)
}
fn full_drag_mouse_capture_observed(&self) -> bool {
self.output
.lock()
.map(|output| output_has_full_drag_mouse_capture(&output))
.unwrap_or(false)
}
fn flush_output_artifact(&self) -> Result<()> {
if let Ok(output) = self.output.lock() {
fs::write(&self.artifacts.output_log, &*output)?;
}
Ok(())
}
}
impl Drop for PanelHarness {
fn drop(&mut self) {
if self.child.try_wait().ok().flatten().is_none() {
let _ = self.child.kill();
let _ = self.child.wait();
}
let _ = self.flush_output_artifact();
let _ = self.reader.take();
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FixtureCleanupReport {
pub fixture_root: PathBuf,
pub artifacts_dir: PathBuf,
pub snapshot_dir: PathBuf,
pub cleanup_attempted: bool,
pub cleanup_success: bool,
pub fixture_root_exists_after: bool,
pub cleanup_error: Option<String>,
pub report_path: PathBuf,
}
pub const READY_FIXTURE_TICKET_TITLE: &str = "Ready E2E Ticket";
pub const PLANNING_FIXTURE_TICKET_TITLE: &str = "Planning E2E Ticket";
#[derive(Debug)]
pub struct FixtureWorkspace {
temp_root: Option<TempDir>,
pub root: PathBuf,
pub workspace: PathBuf,
pub home: PathBuf,
pub xdg_data_home: PathBuf,
pub xdg_state_home: PathBuf,
pub xdg_config_home: PathBuf,
pub xdg_runtime_dir: PathBuf,
pub artifacts_dir: PathBuf,
pub ready_ticket_id: String,
pub planning_ticket_id: String,
}
impl FixtureWorkspace {
pub fn new(binary: &Path) -> Result<Self> {
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!(
"{}-{}-{}",
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 artifacts_dir = artifact_parent.join(fixture_id);
let workspace = root.join("workspace");
let home = root.join("home");
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 {
temp_root: Some(temp_root),
root,
workspace,
home,
xdg_data_home,
xdg_state_home,
xdg_config_home,
xdg_runtime_dir,
artifacts_dir,
ready_ticket_id: String::new(),
planning_ticket_id: String::new(),
};
fixture.write_fixture_metadata("created", None)?;
write_blocking_pod_metadata(&fixture.xdg_data_home, "workspace")?;
write_blocking_pod_metadata(&fixture.xdg_data_home, "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)?;
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(),
],
pod_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 {
PanelHarnessConfig {
binary,
workspace: self.workspace.clone(),
home: self.home.clone(),
xdg_data_home: self.xdg_data_home.clone(),
xdg_state_home: self.xdg_state_home.clone(),
xdg_config_home: self.xdg_config_home.clone(),
xdg_runtime_dir: self.xdg_runtime_dir.clone(),
fixture_root: self.root.clone(),
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(),
],
artifacts_dir: self.artifacts_dir.clone(),
}
}
pub fn panel_config_holding_background_task(
&self,
binary: PathBuf,
task: impl Into<String>,
) -> PanelHarnessConfig {
let mut config = self.panel_config(binary);
config.hold_background_task = Some(task.into());
config
}
pub fn rewind_fixture_config(&self, binary: PathBuf) -> PanelHarnessConfig {
let mut config = self.panel_config(binary);
config.rewind_fixture = true;
config.command_args = vec![
"--workspace".to_string(),
self.workspace.display().to_string(),
"--pod".to_string(),
"e2e-rewind".to_string(),
];
config.artifacts_dir = self.artifacts_dir.join("rewind");
config
}
pub fn cleanup(mut self) -> Result<FixtureCleanupReport> {
self.cleanup_inner(true)
}
fn cleanup_inner(&mut self, strict: bool) -> Result<FixtureCleanupReport> {
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());
}
}
let fixture_root_exists_after = self.root.exists();
let cleanup_success = cleanup_error.is_none() && !fixture_root_exists_after;
let report = FixtureCleanupReport {
fixture_root: self.root.clone(),
artifacts_dir: self.artifacts_dir.clone(),
snapshot_dir,
cleanup_attempted: true,
cleanup_success,
fixture_root_exists_after,
cleanup_error,
report_path: self.artifacts_dir.join("cleanup.json"),
};
fs::write(&report.report_path, serde_json::to_vec_pretty(&report)?)?;
self.write_fixture_metadata("cleaned", Some(&report))?;
if strict && !report.cleanup_success {
return Err(HarnessError::Protocol(format!(
"fixture cleanup failed for {}; see {}",
report.fixture_root.display(),
report.report_path.display()
)));
}
Ok(report)
}
fn write_fixture_metadata(
&self,
phase: &str,
cleanup: Option<&FixtureCleanupReport>,
) -> Result<()> {
fs::create_dir_all(&self.artifacts_dir)?;
fs::write(
self.artifacts_dir.join("fixture.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"phase": phase,
"fixture_root": &self.root,
"workspace": &self.workspace,
"home": &self.home,
"xdg_data_home": &self.xdg_data_home,
"xdg_state_home": &self.xdg_state_home,
"xdg_config_home": &self.xdg_config_home,
"xdg_runtime_dir": &self.xdg_runtime_dir,
"artifacts_dir": &self.artifacts_dir,
"tickets": {
"ready": {
"id": &self.ready_ticket_id,
"title": READY_FIXTURE_TICKET_TITLE,
"state": "ready"
},
"planning": {
"id": &self.planning_ticket_id,
"title": PLANNING_FIXTURE_TICKET_TITLE,
"state": "planning"
}
},
"env_runtime_policy": {
"tested_yoi_uses_env_clear": true,
"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_pod_registry": self.xdg_runtime_dir.join("yoi").join("pods.json"),
"fixture_pod_metadata_root": self.xdg_data_home.join("yoi").join("pods")
},
"tested_yoi_env_policy": tested_yoi_env_policy_overview(),
"cleanup": cleanup,
}))?,
)?;
Ok(())
}
}
impl Drop for FixtureWorkspace {
fn drop(&mut self) {
if self.temp_root.is_some() {
let _ = self.cleanup_inner(false);
}
}
}
pub fn yoi_binary() -> Result<PathBuf> {
Ok(yoi_binary_info()?.binary)
}
pub fn yoi_binary_info() -> Result<BinaryProviderInfo> {
static BINARY_INFO: OnceLock<std::result::Result<BinaryProviderInfo, String>> = OnceLock::new();
match BINARY_INFO.get_or_init(|| resolve_yoi_binary().map_err(|err| err.to_string())) {
Ok(info) => Ok(info.clone()),
Err(message) => Err(HarnessError::Protocol(message.clone())),
}
}
fn resolve_yoi_binary() -> Result<BinaryProviderInfo> {
if let Some(path) = std::env::var_os("YOI_E2E_BIN") {
let workspace_root = workspace_root()?;
let binary = PathBuf::from(path);
let binary = if binary.is_absolute() {
binary
} else {
workspace_root.join(binary)
};
if !binary.exists() {
return Err(HarnessError::MissingBinary(binary));
}
let info = BinaryProviderInfo {
provider: "YOI_E2E_BIN".to_owned(),
binary,
workspace_root,
cargo: None,
build_args: Vec::new(),
build_command: None,
profile: test_profile(),
tested_yoi_subprocess_env: tested_yoi_env_policy_overview(),
};
info.log();
write_binary_provider_artifact(&info)?;
return Ok(info);
}
let workspace_root = workspace_root()?;
let cargo = PathBuf::from(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
let mut args = vec![
"build".to_owned(),
"-p".to_owned(),
"yoi".to_owned(),
"--features".to_owned(),
"e2e-test".to_owned(),
"--bin".to_owned(),
"yoi".to_owned(),
];
if test_profile() == "release" {
args.push("--release".to_owned());
}
let command = command_display(&cargo, &args);
eprintln!("yoi-e2e binary provider=cargo-build command={command}");
let output = Command::new(&cargo)
.args(&args)
.current_dir(&workspace_root)
.output()?;
if !output.status.success() {
return Err(HarnessError::CommandFailed {
program: cargo,
args,
status: output.status,
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
let binary = current_target_profile_dir()?.join(binary_name());
let info = BinaryProviderInfo {
provider: "cargo-build".to_owned(),
binary,
workspace_root,
cargo: Some(cargo),
build_args: args,
build_command: Some(command),
profile: test_profile(),
tested_yoi_subprocess_env: tested_yoi_env_policy_overview(),
};
info.log();
write_binary_provider_artifact(&info)?;
if !info.binary.exists() {
return Err(HarnessError::MissingBinary(info.binary));
}
Ok(info)
}
fn workspace_root() -> Result<PathBuf> {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.ok_or_else(|| HarnessError::Protocol("could not resolve workspace root".to_owned()))
}
fn current_target_profile_dir() -> Result<PathBuf> {
let mut path = std::env::current_exe()?;
while let Some(name) = path.file_name().and_then(|name| name.to_str()) {
if name == "debug" || name == "release" {
return Ok(path);
}
path.pop();
}
Ok(workspace_root()?.join("target").join(test_profile()))
}
fn test_profile() -> String {
let Ok(mut path) = std::env::current_exe() else {
return "debug".to_owned();
};
while let Some(name) = path.file_name().and_then(|name| name.to_str()) {
if name == "debug" || name == "release" {
return name.to_owned();
}
path.pop();
}
"debug".to_owned()
}
fn binary_name() -> String {
format!("yoi{}", std::env::consts::EXE_SUFFIX)
}
fn write_binary_provider_artifact(info: &BinaryProviderInfo) -> Result<()> {
let dir = info.workspace_root.join("target").join("e2e-artifacts");
fs::create_dir_all(&dir)?;
fs::write(
dir.join("binary-provider.json"),
serde_json::to_vec_pretty(info)?,
)?;
Ok(())
}
fn command_display(program: &Path, args: &[String]) -> String {
std::iter::once(program.display().to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>()
.join(" ")
}
fn shell_exec_command(program: &Path, args: &[String]) -> String {
std::iter::once("exec".to_string())
.chain(std::iter::once(shell_quote(&program.to_string_lossy())))
.chain(args.iter().map(|arg| shell_quote(arg)))
.collect::<Vec<_>>()
.join(" ")
}
fn shell_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
let mut quoted = String::from("'");
for ch in value.chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
fn open_pty(size: (u16, u16)) -> Result<(File, File)> {
let mut master = 0;
let mut slave = 0;
let mut winsize = libc::winsize {
ws_row: size.1,
ws_col: size.0,
ws_xpixel: 0,
ws_ypixel: 0,
};
let rc = unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null(),
&mut winsize,
)
};
if rc != 0 {
return Err(io::Error::last_os_error().into());
}
let master = unsafe { File::from_raw_fd(master) };
let slave = unsafe { File::from_raw_fd(slave) };
let _ = unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFL, 0) };
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(());
}
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let file_type = entry.file_type()?;
let target = destination.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &target)?;
} else if file_type.is_file() {
fs::copy(entry.path(), target)?;
} else if file_type.is_symlink() {
// Preserve enough diagnostics for E2E artifacts without following links out of
// the fixture temp root.
fs::write(
target,
format!("symlink -> {}\n", fs::read_link(entry.path())?.display()),
)?;
}
}
Ok(())
}
fn write_blocking_pod_metadata(data_home: &Path, pod_name: &str) -> Result<()> {
let dir = data_home.join("yoi").join("pods").join(pod_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")
}
fn output_has_full_drag_mouse_capture(output: &[u8]) -> bool {
output
.windows(b"\x1b[?1002h".len())
.any(|window| window == b"\x1b[?1002h")
|| output
.windows(b"\x1b[?1003h".len())
.any(|window| window == b"\x1b[?1003h")
}
fn mouse_mode_enabled(output: &[u8], enable: &[u8], disable: &[u8]) -> bool {
let last_enable = last_subsequence_index(output, enable);
let last_disable = last_subsequence_index(output, disable);
match (last_enable, last_disable) {
(Some(enable), Some(disable)) => enable > disable,
(Some(_), None) => true,
_ => false,
}
}
fn last_subsequence_index(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
haystack
.windows(needle.len())
.rposition(|window| window == needle)
}
fn background_task_is_pending(events: &[HarnessEvent], task: &str) -> bool {
let mut pending = false;
for event in events {
if event.data.get("task").and_then(Value::as_str) != Some(task) {
continue;
}
match event.event.as_str() {
"background_task_started" => pending = true,
"background_task_finished" | "background_task_aborted" => pending = false,
_ => {}
}
}
pending
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_host_credentials_default_denied(policy: &EnvPolicy) {
assert!(
policy.env_clear,
"tested yoi subprocesses must use env_clear"
);
assert!(
!policy.path_allowed,
"tested yoi subprocesses should not inherit or allow PATH"
);
assert!(
!policy.allowlist.iter().any(|name| name == "PATH"),
"PATH must not be allowlisted for tested yoi subprocesses"
);
for name in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] {
assert!(
!policy.allowlist.iter().any(|allowed| allowed == name),
"{name} must not be allowlisted for tested yoi subprocesses"
);
assert!(
policy
.provider_credentials_default_deny
.iter()
.any(|denied| denied == name),
"{name} should be recorded as provider credential default-deny"
);
}
}
#[test]
fn tested_yoi_env_policy_is_env_clear_allowlist() {
let fixture = fixture_setup_env_policy();
assert_host_credentials_default_denied(&fixture);
assert_eq!(
fixture.allowlist,
[
"HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"YOI_POD_RUNTIME_COMMAND",
]
);
let panel = panel_env_policy(false);
assert_host_credentials_default_denied(&panel);
assert_eq!(
panel.allowlist,
[
"HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"TERM",
"YOI_TUI_TEST_EVENTS",
"YOI_POD_RUNTIME_COMMAND",
]
);
let panel_with_hold = panel_env_policy(true);
assert_host_credentials_default_denied(&panel_with_hold);
assert!(
panel_with_hold
.allowlist
.iter()
.any(|name| name == "YOI_TUI_TEST_HOLD_BACKGROUND_TASK")
);
}
}