You've already forked ghidra-cli
mirror of
https://github.com/encounter/ghidra-cli.git
synced 2026-03-30 11:12:36 -07:00
3db0af7a3c
Add E2E test infrastructure: - DaemonTestHarness for managing daemon lifecycle in tests - Test fixtures and helpers in tests/common/ - Sample binary fixture for integration tests Add test coverage: - command_tests.rs: version, doctor, config commands - project_tests.rs: project create/list/info/delete, import, analyze - daemon_tests.rs: daemon start/status/ping/stop/clear-cache - query_tests.rs: function list, strings, memory, decompile, xref - unimplemented_tests.rs: 39 tests for graceful error messages Fix CLI bugs: - DisasmArgs: rename count to num_instructions (--instructions/-n) to avoid conflict with QueryOptions.count - GraphExportArgs: add unique arg id for format positional to avoid conflict with QueryOptions.format Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
206 lines
7.0 KiB
Rust
206 lines
7.0 KiB
Rust
//! Common test utilities for E2E tests.
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command};
|
|
use std::sync::Once;
|
|
use std::time::Duration;
|
|
|
|
/// Get path to the sample_binary test fixture.
|
|
pub fn fixture_binary() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests")
|
|
.join("fixtures")
|
|
.join("sample_binary")
|
|
}
|
|
|
|
/// Ensure test project exists with analyzed sample binary.
|
|
/// Uses Once::call_once for idempotent setup across multiple tests.
|
|
pub fn ensure_test_project(project: &str, program: &str) {
|
|
static SETUP: Once = Once::new();
|
|
SETUP.call_once(|| {
|
|
let binary = fixture_binary();
|
|
if !binary.exists() {
|
|
panic!(
|
|
"Test fixture not found: {:?}\nRun: rustc --edition 2021 -o tests/fixtures/sample_binary tests/fixtures/sample_binary.rs",
|
|
binary
|
|
);
|
|
}
|
|
|
|
eprintln!("=== Setting up test project (import + analyze) ===");
|
|
|
|
let mut cmd = assert_cmd::Command::cargo_bin("ghidra").expect("Failed to find ghidra binary");
|
|
let result = cmd
|
|
.arg("import")
|
|
.arg(binary.to_str().unwrap())
|
|
.arg("--project")
|
|
.arg(project)
|
|
.arg("--program")
|
|
.arg(program)
|
|
.timeout(std::time::Duration::from_secs(300))
|
|
.output()
|
|
.expect("Failed to run import command");
|
|
|
|
if !result.status.success() {
|
|
let stderr = String::from_utf8_lossy(&result.stderr);
|
|
let stdout = String::from_utf8_lossy(&result.stdout);
|
|
eprintln!("Import stdout: {}", stdout);
|
|
eprintln!("Import stderr: {}", stderr);
|
|
if !stderr.contains("already exists") && !stdout.contains("already exists") {
|
|
eprintln!("Warning: Import may have failed, but continuing...");
|
|
}
|
|
} else {
|
|
eprintln!("Binary imported successfully");
|
|
}
|
|
|
|
eprintln!("=== Test project setup complete ===");
|
|
});
|
|
}
|
|
|
|
/// Test harness that manages daemon lifecycle for a test suite.
|
|
pub struct DaemonTestHarness {
|
|
child: Child,
|
|
socket_path: PathBuf,
|
|
project: String,
|
|
// Runtime field prevents panic-during-panic in Drop (cannot create Runtime during panic unwinding)
|
|
// and amortizes Runtime creation overhead across all async operations in this harness.
|
|
runtime: tokio::runtime::Runtime,
|
|
}
|
|
|
|
impl DaemonTestHarness {
|
|
/// Start daemon for testing. Blocks until daemon is ready or timeout.
|
|
pub fn new(project: &str, program: &str) -> Result<Self> {
|
|
let socket_path = get_unique_socket_path();
|
|
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_ghidra"));
|
|
cmd.env("GHIDRA_CLI_SOCKET", &socket_path)
|
|
.arg("daemon")
|
|
.arg("start")
|
|
.arg("--foreground")
|
|
.arg("--project")
|
|
.arg(project);
|
|
|
|
let child = cmd.spawn().context("Failed to spawn daemon")?;
|
|
|
|
// ChildGuard ensures daemon process is killed if wait_for_ready() returns early due to error.
|
|
// Without this, failed initialization would leak daemon processes.
|
|
struct ChildGuard(Option<Child>);
|
|
impl Drop for ChildGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(mut child) = self.0.take() {
|
|
let _ = child.kill();
|
|
}
|
|
}
|
|
}
|
|
let mut guard = ChildGuard(Some(child));
|
|
|
|
let runtime = tokio::runtime::Runtime::new()
|
|
.context("Failed to create tokio runtime")?;
|
|
|
|
let mut harness = Self {
|
|
child: guard.0.take().unwrap(),
|
|
socket_path,
|
|
project: project.to_string(),
|
|
runtime,
|
|
};
|
|
|
|
// 120s timeout: Ghidra cold start can be slow on constrained CI environments.
|
|
// Covers worst case without causing flaky tests.
|
|
harness.wait_for_ready(Duration::from_secs(120))?;
|
|
|
|
Ok(harness)
|
|
}
|
|
|
|
/// Wait for daemon to be ready using exponential backoff.
|
|
fn wait_for_ready(&mut self, timeout: Duration) -> Result<()> {
|
|
let start = std::time::Instant::now();
|
|
// Exponential backoff: 100ms initial (responsive for fast starts), 2x multiplier, 12 max attempts.
|
|
// Covers 100ms to ~200s range; total max wait ~409s but typical fast start exits in <5s.
|
|
let mut delay = Duration::from_millis(100);
|
|
let max_attempts = 12;
|
|
|
|
for attempt in 0..max_attempts {
|
|
if start.elapsed() > timeout {
|
|
anyhow::bail!("Daemon failed to start within {}s timeout", timeout.as_secs());
|
|
}
|
|
|
|
std::thread::sleep(delay);
|
|
|
|
if let Ok(mut client) = self.client() {
|
|
match self.runtime.block_on(client.ping()) {
|
|
Ok(true) => return Ok(()),
|
|
Ok(false) => {},
|
|
Err(e) => {
|
|
if attempt == max_attempts - 1 {
|
|
anyhow::bail!("Connection error during ping: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
delay = delay.saturating_mul(2);
|
|
}
|
|
|
|
anyhow::bail!("Daemon failed to respond after {} attempts", max_attempts)
|
|
}
|
|
|
|
/// Get async IPC client connected to daemon.
|
|
pub fn client(&self) -> Result<ghidra_cli::ipc::client::DaemonClient> {
|
|
self.runtime.block_on(async {
|
|
ghidra_cli::ipc::client::DaemonClient::connect().await
|
|
})
|
|
}
|
|
|
|
/// Get socket path for this daemon instance.
|
|
pub fn socket_path(&self) -> &PathBuf {
|
|
&self.socket_path
|
|
}
|
|
|
|
/// Get project name.
|
|
pub fn project(&self) -> &str {
|
|
&self.project
|
|
}
|
|
}
|
|
|
|
impl Drop for DaemonTestHarness {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut client) = self.client() {
|
|
let _ = self.runtime.block_on(client.shutdown());
|
|
}
|
|
|
|
// 5s wait before kill: allows graceful shutdown to complete.
|
|
// Most daemons shut down in <1s; 5s handles slow cleanup without blocking tests indefinitely.
|
|
let timeout = Duration::from_secs(5);
|
|
let start = std::time::Instant::now();
|
|
|
|
while start.elapsed() < timeout {
|
|
if let Ok(Some(_)) = self.child.try_wait() {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
|
|
let _ = self.child.kill();
|
|
let _ = std::fs::remove_file(&self.socket_path);
|
|
}
|
|
}
|
|
|
|
/// Generate unique socket path for test isolation.
|
|
///
|
|
/// UUID guarantees uniqueness across parallel test suites and long-running CI (PID can wrap).
|
|
fn get_unique_socket_path() -> PathBuf {
|
|
std::env::temp_dir().join(format!("ghidra-test-{}.sock", uuid::Uuid::new_v4()))
|
|
}
|
|
|
|
/// Skip test if Ghidra is not available.
|
|
#[macro_export]
|
|
macro_rules! skip_if_no_ghidra {
|
|
() => {
|
|
let doctor = assert_cmd::Command::cargo_bin("ghidra").unwrap().arg("doctor").output();
|
|
if doctor.is_err() || !doctor.unwrap().status.success() {
|
|
eprintln!("Skipping test: Ghidra not available");
|
|
return;
|
|
}
|
|
};
|
|
}
|