mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
1991 lines
69 KiB
Rust
1991 lines
69 KiB
Rust
//! Integration tests for `DaytonaSandbox`.
|
|
//!
|
|
//! These tests require a `DAYTONA_API_KEY` environment variable and network
|
|
//! access. Run with: `cargo test --package arc-workflows -- --ignored daytona`
|
|
|
|
#![allow(
|
|
clippy::absolute_paths,
|
|
clippy::format_push_string,
|
|
clippy::ignore_without_reason,
|
|
clippy::items_after_statements,
|
|
clippy::print_stderr,
|
|
reason = "These Daytona integration tests value explicit scenarios over pedantic style lints."
|
|
)]
|
|
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "These Daytona integration tests use real process env and git CLI fixtures for workflow runs."
|
|
)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use fabro_agent::Sandbox;
|
|
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
|
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox};
|
|
use fabro_static::EnvVars;
|
|
use fabro_store::{ArtifactKey, ArtifactStore};
|
|
use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref};
|
|
use fabro_util::shell;
|
|
use fabro_workflow::artifact;
|
|
use fabro_workflow::context::Context;
|
|
use fabro_workflow::error::Error;
|
|
use fabro_workflow::event::Emitter;
|
|
use fabro_workflow::handler::exit::ExitHandler;
|
|
use fabro_workflow::handler::start::StartHandler;
|
|
use fabro_workflow::handler::{Handler, HandlerRegistry};
|
|
use fabro_workflow::outcome::{Outcome, StageOutcome};
|
|
use fabro_workflow::records::Checkpoint;
|
|
use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions};
|
|
use fabro_workflow::runtime_store::RunStoreHandle;
|
|
use fabro_workflow::test_support::{WorkflowRunner, test_store_dir};
|
|
use object_store::local::LocalFileSystem;
|
|
use tokio_util::sync::CancellationToken;
|
|
use ulid::Ulid;
|
|
|
|
fn test_run_id(label: &str) -> RunId {
|
|
let mut hasher = DefaultHasher::new();
|
|
label.hash(&mut hasher);
|
|
RunId::from(Ulid(u128::from(hasher.finish())))
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "This helper spins up a dedicated current-thread runtime when called from inside an existing Tokio runtime."
|
|
)]
|
|
fn load_run_checkpoint(run_dir: &Path) -> Result<Checkpoint, Box<dyn std::error::Error>> {
|
|
let run_dir = run_dir.to_path_buf();
|
|
let uses_shared_store = run_dir
|
|
.parent()
|
|
.and_then(Path::file_name)
|
|
.is_some_and(|name| name == "scratch");
|
|
let store_dir = if uses_shared_store {
|
|
let runs_dir = run_dir.parent().ok_or("run dir should have parent")?;
|
|
let storage_dir = runs_dir.parent().ok_or("runs dir should have parent")?;
|
|
storage_dir.join("store")
|
|
} else {
|
|
test_store_dir(&run_dir)
|
|
};
|
|
let object_store = Arc::new(LocalFileSystem::new_with_prefix(store_dir)?);
|
|
let store = Arc::new(fabro_store::test_support::test_database(
|
|
object_store,
|
|
"",
|
|
std::time::Duration::from_millis(1),
|
|
None,
|
|
));
|
|
let state = if tokio::runtime::Handle::try_current().is_ok() {
|
|
std::thread::spawn(
|
|
move || -> Result<_, Box<dyn std::error::Error + Send + Sync>> {
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
let run_id =
|
|
if uses_shared_store {
|
|
run_dir
|
|
.file_name()
|
|
.ok_or("run dir should have file name")?
|
|
.to_string_lossy()
|
|
.rsplit('-')
|
|
.next()
|
|
.ok_or("run dir should contain run id suffix")?
|
|
.parse()?
|
|
} else {
|
|
runtime
|
|
.block_on(store.list_runs(
|
|
&fabro_store::ListRunsQuery::default(),
|
|
chrono::Utc::now(),
|
|
))?
|
|
.into_iter()
|
|
.next()
|
|
.ok_or("test store should contain one run")?
|
|
.id
|
|
};
|
|
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
|
let state = runtime.block_on(async {
|
|
for attempt in 0..20 {
|
|
let state = run.state().await?;
|
|
if state.current_checkpoint().is_some() || attempt == 19 {
|
|
return Ok::<_, fabro_store::Error>(state);
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
unreachable!()
|
|
})?;
|
|
Ok(state)
|
|
},
|
|
)
|
|
.join()
|
|
.map_err(|_| "checkpoint loader thread panicked")?
|
|
.map_err(|err| err.to_string())?
|
|
} else {
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()?;
|
|
let run_id = if uses_shared_store {
|
|
run_dir
|
|
.file_name()
|
|
.ok_or("run dir should have file name")?
|
|
.to_string_lossy()
|
|
.rsplit('-')
|
|
.next()
|
|
.ok_or("run dir should contain run id suffix")?
|
|
.parse()?
|
|
} else {
|
|
runtime
|
|
.block_on(
|
|
store.list_runs(&fabro_store::ListRunsQuery::default(), chrono::Utc::now()),
|
|
)?
|
|
.into_iter()
|
|
.next()
|
|
.ok_or("test store should contain one run")?
|
|
.id
|
|
};
|
|
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
|
runtime.block_on(async {
|
|
for attempt in 0..20 {
|
|
let state = run.state().await?;
|
|
if state.current_checkpoint().is_some() || attempt == 19 {
|
|
return Ok::<_, fabro_store::Error>(state);
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
unreachable!()
|
|
})?
|
|
};
|
|
state
|
|
.current_checkpoint()
|
|
.cloned()
|
|
.ok_or_else(|| "checkpoint should exist in run store".into())
|
|
}
|
|
|
|
async fn resolve_checkpoint_text(
|
|
run_dir: &Path,
|
|
run_id: &RunId,
|
|
value: &serde_json::Value,
|
|
) -> Result<String, Box<dyn std::error::Error>> {
|
|
let Some(current) = value.as_str() else {
|
|
return Ok(value.to_string());
|
|
};
|
|
if parse_blob_ref(current).is_none() {
|
|
return Ok(current.to_string());
|
|
}
|
|
|
|
let object_store = Arc::new(LocalFileSystem::new_with_prefix(test_store_dir(run_dir))?);
|
|
let store = fabro_store::test_support::test_database(
|
|
object_store,
|
|
"",
|
|
std::time::Duration::from_millis(1),
|
|
None,
|
|
);
|
|
let run = store.open_run_reader(run_id).await?;
|
|
let run_store = RunStoreHandle::from(run);
|
|
Ok(artifact::resolve_text_or_blob_ref_str(current, &run_store).await?)
|
|
}
|
|
|
|
async fn create_env() -> DaytonaSandbox {
|
|
let creds = load_github_app_credentials();
|
|
create_env_with_github_app(Some(creds)).await
|
|
}
|
|
|
|
fn test_artifact_store(run_dir: &Path) -> ArtifactStore {
|
|
let object_store = Arc::new(
|
|
LocalFileSystem::new_with_prefix(test_store_dir(run_dir))
|
|
.expect("failed to create local artifact store"),
|
|
);
|
|
ArtifactStore::new(object_store, "artifacts")
|
|
}
|
|
|
|
async fn create_env_with_github_app(
|
|
github_app: Option<fabro_github::GitHubCredentials>,
|
|
) -> DaytonaSandbox {
|
|
DaytonaSandbox::new(
|
|
DaytonaConfig::default(),
|
|
github_app,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?")
|
|
}
|
|
|
|
fn load_github_app_credentials() -> fabro_github::GitHubCredentials {
|
|
// Read app_id from ~/.fabro/settings.toml
|
|
let home = dirs::home_dir().expect("No home directory");
|
|
let config_path = home.join(".fabro/settings.toml");
|
|
let config_str = std::fs::read_to_string(&config_path)
|
|
.unwrap_or_else(|e| panic!("Failed to read {}: {e}", config_path.display()));
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Config {
|
|
#[serde(default)]
|
|
git: GitSection,
|
|
}
|
|
#[derive(serde::Deserialize, Default)]
|
|
struct GitSection {
|
|
app_id: Option<String>,
|
|
}
|
|
|
|
let config: Config = toml::from_str(&config_str).expect("Failed to parse settings.toml");
|
|
let app_id = config
|
|
.git
|
|
.app_id
|
|
.expect("app_id not set in settings.toml [git] section");
|
|
|
|
let raw =
|
|
std::env::var(EnvVars::GITHUB_APP_PRIVATE_KEY).expect("GITHUB_APP_PRIVATE_KEY not set");
|
|
let private_key_pem = if raw.starts_with("-----") {
|
|
raw
|
|
} else {
|
|
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &raw)
|
|
.expect("GITHUB_APP_PRIVATE_KEY is not valid base64");
|
|
String::from_utf8(bytes).expect("GITHUB_APP_PRIVATE_KEY decoded to invalid UTF-8")
|
|
};
|
|
fabro_github::GitHubCredentials::App(fabro_github::GitHubAppCredentials {
|
|
app_id,
|
|
private_key_pem,
|
|
slug: None,
|
|
})
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_exec_command() {
|
|
let creds = load_github_app_credentials();
|
|
let env = create_env_with_github_app(Some(creds)).await;
|
|
env.initialize().await.unwrap();
|
|
|
|
let result = env
|
|
.exec_command("echo hello", 30_000, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert!(result.stdout.contains("hello"));
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_exec_command_with_pipe() {
|
|
let creds = load_github_app_credentials();
|
|
let env = create_env_with_github_app(Some(creds)).await;
|
|
env.initialize().await.unwrap();
|
|
|
|
let result = env
|
|
.exec_command("echo hello world | wc -w", 30_000, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert!(result.stdout.trim().contains('2'));
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_exec_command_cancelled() {
|
|
let creds = load_github_app_credentials();
|
|
let env = create_env_with_github_app(Some(creds)).await;
|
|
env.initialize().await.unwrap();
|
|
|
|
let token = CancellationToken::new();
|
|
let token_clone = token.clone();
|
|
|
|
// Cancel the token shortly after starting
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
token_clone.cancel();
|
|
});
|
|
|
|
// Execute a command that would normally take a while
|
|
let result = env
|
|
.exec_command("sleep 10", 30_000, None, None, Some(token))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result.exit_code, None);
|
|
assert!(result.is_cancelled());
|
|
assert_eq!(result.stderr, "Command cancelled");
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_exec_command_local_timeout() {
|
|
let creds = load_github_app_credentials();
|
|
let env = create_env_with_github_app(Some(creds)).await;
|
|
env.initialize().await.unwrap();
|
|
|
|
// Use a tiny timeout_ms of 100ms, our local timeout is 100 + 2000 = 2100ms.
|
|
// If the server doesn't enforce the timeout properly or drops the connection,
|
|
// our local timeout should catch it. To simulate this without making a bad
|
|
// server, we can't easily force the local timeout to hit before the server
|
|
// timeout without mocking. But if we run `sleep 10` and Daytona does NOT
|
|
// respect the short timeout parameter, the local 2.1s timeout will
|
|
// definitely fire. Let's at least test that a 100ms timeout works and
|
|
// doesn't run for 10s.
|
|
let start = std::time::Instant::now();
|
|
let result = env
|
|
.exec_command("sleep 10", 100, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let duration = start.elapsed();
|
|
|
|
assert!(
|
|
duration < std::time::Duration::from_secs(3),
|
|
"Command stalled for longer than the local timeout mechanism"
|
|
);
|
|
assert_eq!(result.exit_code, None);
|
|
assert!(result.is_timed_out());
|
|
assert_eq!(result.stderr, "Command timed out locally");
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_file_round_trip() {
|
|
let env = create_env().await;
|
|
env.initialize().await.unwrap();
|
|
|
|
let test_path = "test_round_trip.txt";
|
|
let content = "Hello from Daytona integration test!";
|
|
|
|
// Write
|
|
env.write_file(test_path, content).await.unwrap();
|
|
|
|
// Exists
|
|
assert!(env.file_exists(test_path).await.unwrap());
|
|
|
|
// Read
|
|
let read_back = env.read_file(test_path, None, None).await.unwrap();
|
|
assert!(read_back.contains(content));
|
|
|
|
// Delete
|
|
env.delete_file(test_path).await.unwrap();
|
|
assert!(!env.file_exists(test_path).await.unwrap());
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_full_lifecycle() {
|
|
let env = create_env().await;
|
|
|
|
// Initialize (creates sandbox + clones repo)
|
|
env.initialize().await.unwrap();
|
|
|
|
// Verify platform
|
|
assert_eq!(env.platform(), "linux");
|
|
|
|
// Verify working directory is accessible
|
|
let result = env
|
|
.exec_command("pwd", 10_000, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
|
|
// List directory
|
|
let entries = env.list_directory(".", None).await.unwrap();
|
|
assert!(!entries.is_empty());
|
|
|
|
// Cleanup (deletes sandbox)
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_snapshot_sandbox() {
|
|
use fabro_sandbox::daytona::DaytonaSnapshotConfig;
|
|
|
|
let config = DaytonaConfig {
|
|
auto_stop_interval: Some(60),
|
|
snapshot: Some(DaytonaSnapshotConfig {
|
|
cpu: Some(2),
|
|
memory: Some(4),
|
|
disk: Some(10),
|
|
dockerfile: Some(fabro_sandbox::daytona::DockerfileSource::Inline(
|
|
"FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string(),
|
|
)),
|
|
}),
|
|
..DaytonaConfig::default()
|
|
};
|
|
|
|
let creds = load_github_app_credentials();
|
|
let env = DaytonaSandbox::new(config, Some(creds), None, None, None, None, None, None)
|
|
.await
|
|
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?");
|
|
env.initialize().await.unwrap();
|
|
|
|
// Verify rg is available (installed by snapshot)
|
|
let result = env
|
|
.exec_command("rg --version", 10_000, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert!(result.stdout.contains("ripgrep"));
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_artifact_sync_uploads_and_rewrites_pointer() {
|
|
let env = create_env().await;
|
|
env.initialize().await.unwrap();
|
|
|
|
// Create a local artifact file (simulating what offload_large_values produces)
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let artifact_content = "x".repeat(150 * 1024); // 150KB
|
|
let artifact_json = serde_json::json!(artifact_content);
|
|
let artifact_file = dir.path().join("response.plan.json");
|
|
std::fs::write(
|
|
&artifact_file,
|
|
serde_json::to_string(&artifact_json).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Build updates with a file:// pointer (as offload_large_values would)
|
|
let pointer = format!("file://{}", artifact_file.display());
|
|
let mut updates = HashMap::new();
|
|
updates.insert("response.plan".to_string(), serde_json::json!(pointer));
|
|
|
|
// Sync — the local file doesn't exist in the Daytona sandbox, so it should
|
|
// upload
|
|
artifact::sync_artifacts_to_env(&mut updates, &env)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Pointer should be rewritten to the Daytona working directory
|
|
let new_pointer = updates["response.plan"].as_str().unwrap();
|
|
let expected_prefix = format!("file://{}/.fabro/artifacts/", env.working_directory());
|
|
assert!(
|
|
new_pointer.starts_with(&expected_prefix),
|
|
"pointer should reference Daytona path, got: {new_pointer}"
|
|
);
|
|
|
|
// Verify the file actually exists in the sandbox by reading it back
|
|
let remote_path = new_pointer.strip_prefix("file://").unwrap();
|
|
assert!(
|
|
env.file_exists(remote_path).await.unwrap(),
|
|
"artifact file should exist in Daytona sandbox at {remote_path}"
|
|
);
|
|
|
|
let remote_content = env.read_file(remote_path, None, None).await.unwrap();
|
|
assert!(
|
|
remote_content.len() > 100 * 1024,
|
|
"remote artifact should be >100KB, got {} bytes",
|
|
remote_content.len()
|
|
);
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Full pipeline E2E on Daytona
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Handler that produces a >100KB context_update to trigger artifact
|
|
/// offloading.
|
|
struct LargeOutputHandler;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Handler for LargeOutputHandler {
|
|
async fn execute(
|
|
&self,
|
|
node: &Node,
|
|
_context: &Context,
|
|
_graph: &Graph,
|
|
_run_dir: &Path,
|
|
_services: &fabro_workflow::handler::EngineServices,
|
|
) -> Result<Outcome, Error> {
|
|
let mut outcome = Outcome::success();
|
|
let large_value = "x".repeat(150 * 1024);
|
|
outcome.context_updates.insert(
|
|
format!("response.{}", node.id),
|
|
serde_json::json!(large_value),
|
|
);
|
|
Ok(outcome)
|
|
}
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_pipeline_artifact_offload_and_sync() {
|
|
let env = create_env().await;
|
|
env.initialize().await.unwrap();
|
|
let env: Arc<dyn Sandbox> = Arc::new(env);
|
|
|
|
// Pipeline: start -> big_output -> exit
|
|
let mut graph = Graph::new("DaytonaArtifactPipeline");
|
|
graph.attrs.insert(
|
|
"goal".to_string(),
|
|
AttrValue::String("Test artifact offload+sync on Daytona".to_string()),
|
|
);
|
|
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
|
|
let mut exit = Node::new("exit");
|
|
exit.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Msquare".to_string()),
|
|
);
|
|
graph.nodes.insert("exit".to_string(), exit);
|
|
|
|
let mut big_output = Node::new("big_output");
|
|
big_output.attrs.insert(
|
|
"label".to_string(),
|
|
AttrValue::String("Big Output".to_string()),
|
|
);
|
|
graph.nodes.insert("big_output".to_string(), big_output);
|
|
|
|
graph.edges.push(Edge::new("start", "big_output"));
|
|
graph.edges.push(Edge::new("big_output", "exit"));
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut registry = HandlerRegistry::new(Box::new(LargeOutputHandler));
|
|
registry.register("start", Box::new(StartHandler));
|
|
registry.register("exit", Box::new(ExitHandler));
|
|
|
|
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone());
|
|
let run_options = RunOptions {
|
|
settings: WorkflowSettings::default(),
|
|
run_dir: dir.path().to_path_buf(),
|
|
cancel_token: CancellationToken::new(),
|
|
run_id: test_run_id("test-run"),
|
|
labels: std::collections::HashMap::new(),
|
|
workflow_slug: None,
|
|
github_app: None,
|
|
base_branch: None,
|
|
display_base_sha: None,
|
|
pre_run_git: None,
|
|
fork_source_ref: None,
|
|
git: None,
|
|
};
|
|
let outcome = engine
|
|
.run(&graph, &run_options)
|
|
.await
|
|
.expect("pipeline should succeed");
|
|
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
|
|
|
// Checkpoint should persist a durable blob ref.
|
|
let checkpoint = load_run_checkpoint(dir.path()).expect("checkpoint should load");
|
|
let pointer_value = checkpoint
|
|
.context_values
|
|
.get("response.big_output")
|
|
.expect("context should have response.big_output");
|
|
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
|
assert!(
|
|
parse_blob_ref(pointer_str).is_some(),
|
|
"checkpoint should persist a blob ref"
|
|
);
|
|
let resolved = resolve_checkpoint_text(dir.path(), &run_options.run_id, pointer_value)
|
|
.await
|
|
.expect("offloaded value should resolve through the run store");
|
|
assert_eq!(
|
|
resolved,
|
|
"x".repeat(150 * 1024),
|
|
"offloaded value should round-trip through the run store"
|
|
);
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI Backend on Daytona — real CLI tools via exec_command
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Git checkpoint E2E on Daytona
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Handler that writes a file via exec_command so git has something to commit.
|
|
struct FileWriterHandler;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Handler for FileWriterHandler {
|
|
async fn execute(
|
|
&self,
|
|
node: &Node,
|
|
_context: &Context,
|
|
_graph: &Graph,
|
|
_run_dir: &Path,
|
|
services: &fabro_workflow::handler::EngineServices,
|
|
) -> Result<Outcome, Error> {
|
|
let content = format!("output from {}", node.id);
|
|
let cmd = format!("echo '{content}' > {}.txt", node.id);
|
|
let _ = services
|
|
.run
|
|
.sandbox
|
|
.exec_command(&cmd, 10_000, None, None, None)
|
|
.await;
|
|
Ok(Outcome::success())
|
|
}
|
|
}
|
|
|
|
/// Set up git inside a Daytona sandbox for checkpoint commits.
|
|
/// Returns (run_id, base_sha, branch_name) on success.
|
|
async fn setup_daytona_git(sandbox: &dyn Sandbox) -> (RunId, String, String) {
|
|
// Get current HEAD as base SHA
|
|
let sha_result = sandbox
|
|
.exec_command("git rev-parse HEAD", 10_000, None, None, None)
|
|
.await
|
|
.expect("git rev-parse HEAD should succeed");
|
|
assert_eq!(
|
|
sha_result.exit_code,
|
|
Some(0),
|
|
"git rev-parse HEAD failed: {}",
|
|
sha_result.stderr
|
|
);
|
|
let base_sha = sha_result.stdout.trim().to_string();
|
|
|
|
let run_id = RunId::from(Ulid::new());
|
|
let branch_name = format!("fabro/run/{run_id}");
|
|
|
|
let checkout_cmd = format!("git checkout -b {branch_name}");
|
|
let checkout_result = sandbox
|
|
.exec_command(&checkout_cmd, 10_000, None, None, None)
|
|
.await
|
|
.expect("git checkout should succeed");
|
|
assert_eq!(
|
|
checkout_result.exit_code,
|
|
Some(0),
|
|
"git checkout -b failed (exit {:?}): stdout={} stderr={}",
|
|
checkout_result.exit_code,
|
|
checkout_result.stdout,
|
|
checkout_result.stderr
|
|
);
|
|
|
|
(run_id, base_sha, branch_name)
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_git_checkpoint_remote_emits_events() {
|
|
let env = create_env().await;
|
|
env.initialize().await.unwrap();
|
|
let env: Arc<dyn Sandbox> = Arc::new(env);
|
|
|
|
// Install git if not available (the default ubuntu:22.04 image may not have it)
|
|
let git_check = env
|
|
.exec_command("git --version", 10_000, None, None, None)
|
|
.await;
|
|
if git_check.as_ref().map_or(true, |r| !r.is_success()) {
|
|
let install = env
|
|
.exec_command(
|
|
"apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1",
|
|
120_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("apt-get install git should not error");
|
|
assert_eq!(
|
|
install.exit_code,
|
|
Some(0),
|
|
"git install failed: {}",
|
|
install.stderr
|
|
);
|
|
}
|
|
|
|
// Set up git in the sandbox
|
|
let (_run_id, base_sha, branch_name) = setup_daytona_git(&*env).await;
|
|
|
|
// Pipeline: start -> work -> exit
|
|
let mut graph = Graph::new("DaytonaGitCheckpoint");
|
|
graph.attrs.insert(
|
|
"goal".to_string(),
|
|
AttrValue::String("Test Remote git checkpoint".to_string()),
|
|
);
|
|
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
|
|
let mut exit = Node::new("exit");
|
|
exit.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Msquare".to_string()),
|
|
);
|
|
graph.nodes.insert("exit".to_string(), exit);
|
|
|
|
let mut work = Node::new("work");
|
|
work.attrs
|
|
.insert("label".to_string(), AttrValue::String("Work".to_string()));
|
|
graph.nodes.insert("work".to_string(), work);
|
|
|
|
graph.edges.push(Edge::new("start", "work"));
|
|
graph.edges.push(Edge::new("work", "exit"));
|
|
|
|
// Set up event collection
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let emitter = Emitter::default();
|
|
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
{
|
|
let events_clone = Arc::clone(&events);
|
|
emitter.on_event(move |event| {
|
|
events_clone.lock().unwrap().push(event.clone());
|
|
});
|
|
}
|
|
|
|
let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler));
|
|
registry.register("start", Box::new(StartHandler));
|
|
registry.register("exit", Box::new(ExitHandler));
|
|
|
|
let engine = WorkflowRunner::new(registry, Arc::new(emitter), env.clone());
|
|
let run_options = RunOptions {
|
|
settings: WorkflowSettings::default(),
|
|
run_dir: dir.path().to_path_buf(),
|
|
cancel_token: CancellationToken::new(),
|
|
run_id: test_run_id("git-cp-test"),
|
|
labels: std::collections::HashMap::new(),
|
|
workflow_slug: None,
|
|
github_app: None,
|
|
base_branch: None,
|
|
display_base_sha: None,
|
|
pre_run_git: None,
|
|
fork_source_ref: None,
|
|
git: Some(GitCheckpointOptions {
|
|
base_sha: Some(base_sha),
|
|
run_branch: Some(branch_name),
|
|
meta_branch: None,
|
|
}),
|
|
};
|
|
let outcome = engine
|
|
.run(&graph, &run_options)
|
|
.await
|
|
.expect("pipeline should succeed");
|
|
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
|
|
|
// Assert CheckpointCompleted events with git SHAs were emitted
|
|
{
|
|
let events = events.lock().unwrap();
|
|
let git_events: Vec<_> = events
|
|
.iter()
|
|
.filter_map(|e| {
|
|
if e.event_name() != "checkpoint.completed" {
|
|
return None;
|
|
}
|
|
let properties = e.properties().ok()?;
|
|
Some((
|
|
e.node_id.clone()?,
|
|
properties.get("git_commit_sha")?.as_str()?.to_string(),
|
|
))
|
|
})
|
|
.collect();
|
|
// Only the "work" node gets a checkpoint — start is skipped and exit breaks
|
|
// before the checkpoint code runs.
|
|
assert_eq!(
|
|
git_events.len(),
|
|
1,
|
|
"expected 1 CheckpointCompleted event with SHA (work node only), got {}",
|
|
git_events.len()
|
|
);
|
|
assert!(
|
|
git_events
|
|
.iter()
|
|
.all(|(_, sha)| sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit())),
|
|
"all SHAs should be 40-char hex, got: {git_events:?}"
|
|
);
|
|
}
|
|
|
|
// Verify the persisted checkpoint snapshot has git_commit_sha
|
|
let checkpoint = load_run_checkpoint(dir.path()).expect("checkpoint should load");
|
|
assert!(
|
|
checkpoint.git_commit_sha.is_some(),
|
|
"checkpoint should have git_commit_sha"
|
|
);
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Daytona shadow commit E2E with sandbox-native metadata
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// End-to-end test: pipeline with git checkpointing enabled + `meta_branch`
|
|
/// writes shadow branch in the sandbox repo and includes `Fabro-Checkpoint`
|
|
/// trailer in sandbox commits.
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_git_checkpoint_with_shadow_branch() {
|
|
let env = create_env().await;
|
|
env.initialize().await.unwrap();
|
|
let env: Arc<dyn Sandbox> = Arc::new(env);
|
|
|
|
// Install git if not available
|
|
let git_check = env
|
|
.exec_command("git --version", 10_000, None, None, None)
|
|
.await;
|
|
if git_check.as_ref().map_or(true, |r| !r.is_success()) {
|
|
let install = env
|
|
.exec_command(
|
|
"apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1",
|
|
120_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("apt-get install git should not error");
|
|
assert_eq!(
|
|
install.exit_code,
|
|
Some(0),
|
|
"git install failed: {}",
|
|
install.stderr
|
|
);
|
|
}
|
|
|
|
// Set up git in the sandbox
|
|
let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await;
|
|
|
|
// Pipeline: start -> work -> exit
|
|
let mut graph = Graph::new("DaytonaShadowBranch");
|
|
graph.attrs.insert(
|
|
"goal".to_string(),
|
|
AttrValue::String("Test Daytona shadow branch".to_string()),
|
|
);
|
|
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
|
|
let mut exit = Node::new("exit");
|
|
exit.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Msquare".to_string()),
|
|
);
|
|
graph.nodes.insert("exit".to_string(), exit);
|
|
|
|
let mut work = Node::new("work");
|
|
work.attrs
|
|
.insert("label".to_string(), AttrValue::String("Work".to_string()));
|
|
graph.nodes.insert("work".to_string(), work);
|
|
|
|
graph.edges.push(Edge::new("start", "work"));
|
|
graph.edges.push(Edge::new("work", "exit"));
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
// Write graph.fabro so init_run can read it
|
|
std::fs::write(dir.path().join("graph.fabro"), "digraph {}").unwrap();
|
|
|
|
let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler));
|
|
registry.register("start", Box::new(StartHandler));
|
|
registry.register("exit", Box::new(ExitHandler));
|
|
|
|
let meta_branch = format!("fabro/meta/{run_id}");
|
|
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone());
|
|
let run_options = RunOptions {
|
|
settings: WorkflowSettings::default(),
|
|
run_dir: dir.path().to_path_buf(),
|
|
cancel_token: CancellationToken::new(),
|
|
run_id,
|
|
labels: std::collections::HashMap::new(),
|
|
workflow_slug: None,
|
|
github_app: None,
|
|
base_branch: None,
|
|
display_base_sha: None,
|
|
pre_run_git: None,
|
|
fork_source_ref: None,
|
|
git: Some(GitCheckpointOptions {
|
|
base_sha: Some(base_sha),
|
|
run_branch: Some(branch_name),
|
|
meta_branch: Some(meta_branch.clone()),
|
|
}),
|
|
};
|
|
let outcome = engine
|
|
.run(&graph, &run_options)
|
|
.await
|
|
.expect("pipeline should succeed");
|
|
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
|
|
|
// Assert shadow branch in the sandbox has checkpoint data
|
|
let run_json = env
|
|
.exec_command(
|
|
&format!("git show refs/heads/{meta_branch}:run.json"),
|
|
10_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("git show should succeed");
|
|
assert_eq!(run_json.exit_code, Some(0), "{}", run_json.stderr);
|
|
let projection: fabro_store::RunProjection =
|
|
serde_json::from_slice(run_json.stdout.as_bytes()).expect("run.json should parse");
|
|
let checkpoint = projection
|
|
.current_checkpoint()
|
|
.cloned()
|
|
.expect("shadow branch should contain checkpoint data");
|
|
assert!(
|
|
!checkpoint.completed_nodes.is_empty(),
|
|
"checkpoint should have completed nodes"
|
|
);
|
|
assert!(
|
|
checkpoint.completed_nodes.contains(&"work".to_string()),
|
|
"checkpoint should contain the 'work' node"
|
|
);
|
|
|
|
// Assert sandbox commit has Fabro-Checkpoint trailer
|
|
let log_result = env
|
|
.exec_command("git log --format=%B -1", 10_000, None, None, None)
|
|
.await
|
|
.expect("git log should succeed");
|
|
assert_eq!(log_result.exit_code, Some(0));
|
|
let commit_msg = log_result.stdout.trim().to_string();
|
|
assert!(
|
|
commit_msg.contains("Fabro-Checkpoint:"),
|
|
"sandbox commit should have Fabro-Checkpoint trailer, got:\n{commit_msg}"
|
|
);
|
|
assert!(
|
|
commit_msg.contains("Fabro-Run:"),
|
|
"sandbox commit should have Fabro-Run trailer, got:\n{commit_msg}"
|
|
);
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Artifact collection e2e — Daytona sandbox
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Handler that creates artifact files via exec_command on the sandbox.
|
|
struct AssetCreatorHandler;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Handler for AssetCreatorHandler {
|
|
async fn execute(
|
|
&self,
|
|
_node: &Node,
|
|
_context: &Context,
|
|
_graph: &Graph,
|
|
_run_dir: &Path,
|
|
services: &fabro_workflow::handler::EngineServices,
|
|
) -> Result<Outcome, Error> {
|
|
let script = concat!(
|
|
"mkdir -p test-results && ",
|
|
"echo '<testsuites><testsuite name=\"example\"/></testsuites>' > test-results/report.xml && ",
|
|
"echo 'test output' > test-results/output.txt"
|
|
);
|
|
services
|
|
.run
|
|
.sandbox
|
|
.exec_command(script, 30_000, None, None, None)
|
|
.await
|
|
.map_err(|e| Error::handler(format!("exec failed: {e}")))?;
|
|
Ok(Outcome::success())
|
|
}
|
|
}
|
|
|
|
/// Daytona sandbox: artifact collection discovers files on the remote sandbox
|
|
/// and downloads them to the local logs directory.
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_asset_collection() {
|
|
let env = create_env().await;
|
|
env.initialize().await.unwrap();
|
|
let env: Arc<dyn Sandbox> = Arc::new(env);
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let mut registry = HandlerRegistry::new(Box::new(AssetCreatorHandler));
|
|
registry.register("start", Box::new(StartHandler));
|
|
registry.register("exit", Box::new(ExitHandler));
|
|
|
|
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone());
|
|
|
|
let mut graph = Graph::new("DaytonaAssetTest");
|
|
graph.attrs.insert(
|
|
"goal".to_string(),
|
|
AttrValue::String("Test artifact collection on Daytona".to_string()),
|
|
);
|
|
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
|
|
let mut create_assets = Node::new("create_assets");
|
|
create_assets.attrs.insert(
|
|
"label".to_string(),
|
|
AttrValue::String("Create Assets".to_string()),
|
|
);
|
|
graph
|
|
.nodes
|
|
.insert("create_assets".to_string(), create_assets);
|
|
|
|
let mut exit = Node::new("exit");
|
|
exit.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Msquare".to_string()),
|
|
);
|
|
graph.nodes.insert("exit".to_string(), exit);
|
|
|
|
graph.edges.push(Edge::new("start", "create_assets"));
|
|
graph.edges.push(Edge::new("create_assets", "exit"));
|
|
|
|
let run_options = RunOptions {
|
|
settings: WorkflowSettings {
|
|
run: fabro_types::settings::RunNamespace {
|
|
artifacts: fabro_types::settings::run::ArtifactsSettings {
|
|
include: vec!["test-results/**".to_string()],
|
|
},
|
|
..fabro_types::settings::RunNamespace::default()
|
|
},
|
|
..WorkflowSettings::default()
|
|
},
|
|
run_dir: dir.path().to_path_buf(),
|
|
cancel_token: CancellationToken::new(),
|
|
run_id: test_run_id("artifact-test-daytona"),
|
|
labels: std::collections::HashMap::new(),
|
|
workflow_slug: None,
|
|
github_app: None,
|
|
base_branch: None,
|
|
display_base_sha: None,
|
|
pre_run_git: None,
|
|
fork_source_ref: None,
|
|
git: None,
|
|
};
|
|
let outcome = engine
|
|
.run(&graph, &run_options)
|
|
.await
|
|
.expect("pipeline should succeed");
|
|
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
|
|
|
let content = String::from_utf8(
|
|
test_artifact_store(dir.path())
|
|
.get(
|
|
&run_options.run_id,
|
|
&ArtifactKey::new(
|
|
StageId::new("create_assets", 1),
|
|
1,
|
|
"test-results/report.xml",
|
|
),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.expect("artifact should be stored from Daytona sandbox")
|
|
.to_vec(),
|
|
)
|
|
.unwrap();
|
|
assert!(content.contains("testsuites"));
|
|
assert!(
|
|
!dir.path().join("cache").join("artifacts").exists(),
|
|
"artifact scratch cache should not be created"
|
|
);
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_ssh_access() {
|
|
let env = create_env().await;
|
|
env.initialize().await.unwrap();
|
|
|
|
let ssh_command = env.create_ssh_access(Some(60.0)).await.unwrap();
|
|
assert!(!ssh_command.is_empty(), "ssh_command should not be empty");
|
|
assert!(
|
|
ssh_command.contains("ssh"),
|
|
"ssh_command should contain 'ssh': {ssh_command}",
|
|
);
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_ssh_access_before_init_fails() {
|
|
let env = create_env().await;
|
|
|
|
let result = env.create_ssh_access(Some(60.0)).await;
|
|
assert!(result.is_err(), "should fail before initialize()");
|
|
assert!(
|
|
result.unwrap_err().to_string().contains("not initialized"),
|
|
"error should mention not initialized"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GitHub App Installation Access Token (IAT) clone tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// E2E: Clone the current (private) repo using GitHub App IAT credentials.
|
|
/// Verifies the full flow: JWT signing, installation lookup, token creation,
|
|
/// clone.
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_clone_private_repo_with_github_app_iat() {
|
|
let creds = load_github_app_credentials();
|
|
let env = create_env_with_github_app(Some(creds)).await;
|
|
|
|
// initialize() clones the current repo — with IAT credentials this should
|
|
// succeed
|
|
env.initialize().await.unwrap();
|
|
|
|
// Verify the clone worked: CLAUDE.md should exist in the workspace
|
|
let result = env
|
|
.exec_command("test -f CLAUDE.md && echo EXISTS", 10_000, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
result.exit_code,
|
|
Some(0),
|
|
"CLAUDE.md should exist after clone"
|
|
);
|
|
assert!(
|
|
result.stdout.contains("EXISTS"),
|
|
"clone should have populated the workspace"
|
|
);
|
|
|
|
// Install git if not available (the default ubuntu:22.04 image may not have it)
|
|
let git_check = env
|
|
.exec_command("git --version", 10_000, None, None, None)
|
|
.await;
|
|
if git_check.as_ref().map_or(true, |r| !r.is_success()) {
|
|
let install = env
|
|
.exec_command(
|
|
"apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1",
|
|
120_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("apt-get install git should not error");
|
|
assert_eq!(
|
|
install.exit_code,
|
|
Some(0),
|
|
"git install failed: {}",
|
|
install.stderr
|
|
);
|
|
}
|
|
|
|
// Verify this is actually the fabro repo
|
|
let result = env
|
|
.exec_command("git remote get-url origin", 10_000, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.exit_code, Some(0));
|
|
assert!(
|
|
result.stdout.contains("fabro-sh/fabro"),
|
|
"origin should point to fabro-sh/fabro, got: {}",
|
|
result.stdout.trim()
|
|
);
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
/// E2E: Verify that repos in an installed org get credentials (needed for
|
|
/// pushing).
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_clone_public_repo_gets_credentials() {
|
|
let creds = load_github_app_credentials();
|
|
|
|
// Directly test resolve_clone_credentials against a repo in an org where the
|
|
// app is installed
|
|
let (username, password) = fabro_github::resolve_clone_credentials(
|
|
&fabro_github::GitHubContext::new(&creds, &fabro_github::github_api_base_url()),
|
|
"fabro-sh",
|
|
"fabro",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
username.as_deref(),
|
|
Some("x-access-token"),
|
|
"installed org repo should get credentials for pushing"
|
|
);
|
|
assert!(
|
|
password.is_some(),
|
|
"installed org repo should get a token for pushing"
|
|
);
|
|
}
|
|
|
|
/// E2E: Verify that requesting an IAT for a repo the app isn't installed on
|
|
/// gives a clear error message.
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_iat_not_installed_gives_clear_error() {
|
|
let creds = load_github_app_credentials();
|
|
|
|
let result = fabro_github::resolve_clone_credentials(
|
|
&fabro_github::GitHubContext::new(&creds, &fabro_github::github_api_base_url()),
|
|
"torvalds",
|
|
"linux",
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"should fail for repo the app isn't installed on"
|
|
);
|
|
let err = result.unwrap_err();
|
|
let err = format!("{err:#}");
|
|
assert!(
|
|
err.contains("not installed"),
|
|
"error should mention 'not installed', got: {err}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Push run branch to origin after each checkpoint (GitHub App)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// E2E: After each remote checkpoint, the run branch is pushed to origin.
|
|
/// Verifies the branch appears on the remote via `git ls-remote`.
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_git_push_run_branch_to_origin() {
|
|
let creds = load_github_app_credentials();
|
|
let env = create_env_with_github_app(Some(creds)).await;
|
|
env.initialize().await.unwrap();
|
|
let env: Arc<dyn Sandbox> = Arc::new(env);
|
|
|
|
// Install git if not available
|
|
let git_check = env
|
|
.exec_command("git --version", 10_000, None, None, None)
|
|
.await;
|
|
if git_check.as_ref().map_or(true, |r| !r.is_success()) {
|
|
let install = env
|
|
.exec_command(
|
|
"apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1",
|
|
120_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("apt-get install git should not error");
|
|
assert_eq!(
|
|
install.exit_code,
|
|
Some(0),
|
|
"git install failed: {}",
|
|
install.stderr
|
|
);
|
|
}
|
|
|
|
// Set up git in the sandbox
|
|
let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await;
|
|
|
|
// Pipeline: start -> work -> exit
|
|
let mut graph = Graph::new("DaytonaGitPush");
|
|
graph.attrs.insert(
|
|
"goal".to_string(),
|
|
AttrValue::String("Test push run branch to origin".to_string()),
|
|
);
|
|
|
|
let mut start = Node::new("start");
|
|
start.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Mdiamond".to_string()),
|
|
);
|
|
graph.nodes.insert("start".to_string(), start);
|
|
|
|
let mut exit = Node::new("exit");
|
|
exit.attrs.insert(
|
|
"shape".to_string(),
|
|
AttrValue::String("Msquare".to_string()),
|
|
);
|
|
graph.nodes.insert("exit".to_string(), exit);
|
|
|
|
let mut work = Node::new("work");
|
|
work.attrs
|
|
.insert("label".to_string(), AttrValue::String("Work".to_string()));
|
|
graph.nodes.insert("work".to_string(), work);
|
|
|
|
graph.edges.push(Edge::new("start", "work"));
|
|
graph.edges.push(Edge::new("work", "exit"));
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler));
|
|
registry.register("start", Box::new(StartHandler));
|
|
registry.register("exit", Box::new(ExitHandler));
|
|
|
|
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone());
|
|
let run_options = RunOptions {
|
|
settings: WorkflowSettings::default(),
|
|
run_dir: dir.path().to_path_buf(),
|
|
cancel_token: CancellationToken::new(),
|
|
run_id,
|
|
labels: std::collections::HashMap::new(),
|
|
workflow_slug: None,
|
|
github_app: None,
|
|
base_branch: None,
|
|
display_base_sha: None,
|
|
pre_run_git: None,
|
|
fork_source_ref: None,
|
|
git: Some(GitCheckpointOptions {
|
|
base_sha: Some(base_sha),
|
|
run_branch: Some(branch_name.clone()),
|
|
meta_branch: None,
|
|
}),
|
|
};
|
|
let outcome = engine
|
|
.run(&graph, &run_options)
|
|
.await
|
|
.expect("pipeline should succeed");
|
|
assert_eq!(outcome.status, StageOutcome::Succeeded);
|
|
|
|
// Verify the run branch was pushed to origin
|
|
let ls_remote_cmd = format!("git ls-remote --heads origin {branch_name}");
|
|
let ls_result = env
|
|
.exec_command(&ls_remote_cmd, 30_000, None, None, None)
|
|
.await
|
|
.expect("git ls-remote should succeed");
|
|
assert_eq!(
|
|
ls_result.exit_code,
|
|
Some(0),
|
|
"git ls-remote failed: {}",
|
|
ls_result.stdout
|
|
);
|
|
assert!(
|
|
ls_result.stdout.contains(&branch_name),
|
|
"run branch should exist on origin after push, got: {}",
|
|
ls_result.stdout.trim()
|
|
);
|
|
|
|
// Clean up the remote branch
|
|
let delete_cmd = format!("git push origin --delete {branch_name}");
|
|
let delete_result = env
|
|
.exec_command(&delete_cmd, 30_000, None, None, None)
|
|
.await;
|
|
if let Ok(r) = &delete_result {
|
|
if !r.is_success() {
|
|
eprintln!(
|
|
"Warning: failed to delete remote branch {branch_name}: {}",
|
|
r.stdout
|
|
);
|
|
}
|
|
}
|
|
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
/// Diagnose toolbox proxy staleness after idle time.
|
|
///
|
|
/// Creates a sandbox, runs a command, sleeps for increasing durations, then
|
|
/// retries. If a call fails, makes raw HTTP requests to capture the actual
|
|
/// underlying error that the SDK normally swallows.
|
|
///
|
|
/// Run: cargo test -p arc-workflows -- --ignored
|
|
/// daytona_toolbox_idle_diagnostic --nocapture
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_toolbox_idle_diagnostic() {
|
|
let creds = load_github_app_credentials();
|
|
let env = create_env_with_github_app(Some(creds)).await;
|
|
env.initialize().await.unwrap();
|
|
|
|
// 1. Verify toolbox works immediately after init
|
|
let result = env
|
|
.exec_command("echo alive", 30_000, None, None, None)
|
|
.await;
|
|
eprintln!(
|
|
"[t=0s] exec_command after init: {:?}",
|
|
result.as_ref().map(|r| r.exit_code)
|
|
);
|
|
assert!(
|
|
result.is_ok(),
|
|
"exec_command should work immediately after init"
|
|
);
|
|
|
|
let sandbox_name = env.sandbox_info();
|
|
eprintln!("[t=0s] sandbox: {sandbox_name}");
|
|
|
|
// 2. Sleep for increasing durations and test
|
|
for sleep_secs in [1, 2, 3] {
|
|
eprintln!("\n--- sleeping {sleep_secs}s ---");
|
|
tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await;
|
|
|
|
let result = env
|
|
.exec_command("echo alive", 30_000, None, None, None)
|
|
.await;
|
|
|
|
match &result {
|
|
Ok(r) => {
|
|
eprintln!(
|
|
"[t=+{sleep_secs}s] OK exit_code={:?} stdout={}",
|
|
r.exit_code,
|
|
r.stdout.trim()
|
|
);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("[t=+{sleep_secs}s] FAILED: {e}");
|
|
|
|
// Diagnose with raw HTTP calls
|
|
let api_key = std::env::var(EnvVars::DAYTONA_API_KEY).unwrap_or_default();
|
|
let client = fabro_http::HttpClientBuilder::new()
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.build()
|
|
.unwrap();
|
|
let api_url = std::env::var(EnvVars::DAYTONA_API_URL)
|
|
.or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL))
|
|
.unwrap_or_else(|_| "https://app.daytona.io/api".to_string());
|
|
|
|
// Check sandbox state
|
|
let state_resp = client
|
|
.get(format!("{api_url}/sandbox/{sandbox_name}"))
|
|
.bearer_auth(&api_key)
|
|
.send()
|
|
.await;
|
|
match state_resp {
|
|
Ok(resp) => {
|
|
let body = resp.text().await.unwrap_or_default();
|
|
let state = serde_json::from_str::<serde_json::Value>(&body)
|
|
.ok()
|
|
.and_then(|v| v.get("state").cloned());
|
|
eprintln!("[diag] sandbox state: {state:?}");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("[diag] sandbox API failed: {e}");
|
|
}
|
|
}
|
|
|
|
// Get toolbox proxy URL and try a direct call
|
|
let proxy_resp = client
|
|
.get(format!(
|
|
"{api_url}/sandbox/{sandbox_name}/toolbox-proxy-url"
|
|
))
|
|
.bearer_auth(&api_key)
|
|
.send()
|
|
.await;
|
|
if let Ok(resp) = proxy_resp {
|
|
let body = resp.text().await.unwrap_or_default();
|
|
eprintln!(
|
|
"[diag] proxy URL response: {}",
|
|
&body[..body.len().min(200)]
|
|
);
|
|
if let Some(url) = serde_json::from_str::<serde_json::Value>(&body)
|
|
.ok()
|
|
.and_then(|v| v.get("url").and_then(|u| u.as_str()).map(String::from))
|
|
{
|
|
let toolbox_url = format!("{url}/{sandbox_name}/process/execute");
|
|
eprintln!("[diag] trying direct POST to {toolbox_url}");
|
|
let direct = client
|
|
.post(&toolbox_url)
|
|
.bearer_auth(&api_key)
|
|
.json(&serde_json::json!({"command": "echo diag", "timeout": 10}))
|
|
.send()
|
|
.await;
|
|
match direct {
|
|
Ok(resp) => {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
eprintln!(
|
|
"[diag] direct call: {status} body={}",
|
|
&body[..body.len().min(300)]
|
|
);
|
|
}
|
|
Err(e) => {
|
|
// Walk the FULL error source chain
|
|
let mut msg = format!("[diag] direct call FAILED: {e}");
|
|
let mut source: Option<&dyn std::error::Error> =
|
|
std::error::Error::source(&e);
|
|
while let Some(cause) = source {
|
|
msg.push_str(&format!("\n caused by: {cause}"));
|
|
source = cause.source();
|
|
}
|
|
eprintln!("{msg}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
panic!("exec_command failed after {sleep_secs}s idle: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
eprintln!("\n=== PASS: all idle durations survived ===");
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
/// E2E test for `fabro cp` against a live Daytona sandbox.
|
|
///
|
|
/// Creates a sandbox, reconnects via `cp::reconnect`,
|
|
/// uploads a file, downloads it back, and verifies the round-trip.
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_cp_upload_download_round_trip() {
|
|
use fabro_sandbox::reconnect::reconnect;
|
|
use fabro_types::{RunSandboxInstance, SandboxProviderKind};
|
|
|
|
// 1. Create and initialize a real Daytona sandbox
|
|
let env = create_env().await;
|
|
env.initialize().await.unwrap();
|
|
|
|
let sandbox_name = env.sandbox_info();
|
|
assert!(
|
|
!sandbox_name.is_empty(),
|
|
"sandbox_info() should return the Daytona sandbox name"
|
|
);
|
|
|
|
// 2. Build initialized sandbox metadata (same as `fabro run` would persist)
|
|
let record = RunSandboxInstance {
|
|
provider: SandboxProviderKind::Daytona,
|
|
image: None,
|
|
snapshot: None,
|
|
runtime: fabro_types::RunSandboxRuntime {
|
|
id: sandbox_name.clone(),
|
|
working_directory: env.working_directory().to_string(),
|
|
repo_cloned: Some(false),
|
|
clone_origin_url: None,
|
|
clone_branch: None,
|
|
workspace_root: Some("/home/daytona/workspace".to_string()),
|
|
repos_root: Some("/home/daytona/repos".to_string()),
|
|
primary_repo_path: None,
|
|
primary_repo_link: None,
|
|
},
|
|
};
|
|
|
|
// 3. Reconnect via the real cp::reconnect path
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let reconnected = reconnect(&record, None)
|
|
.await
|
|
.expect("reconnect should succeed");
|
|
|
|
// 4. Upload: write a local file, then upload it to the sandbox
|
|
let upload_content = b"hello from fabro cp e2e test\n";
|
|
let local_upload = tmp.path().join("upload.txt");
|
|
std::fs::write(&local_upload, upload_content).unwrap();
|
|
|
|
reconnected
|
|
.upload_file_from_local(&local_upload, "cp_test_upload.txt")
|
|
.await
|
|
.expect("upload_file_from_local should succeed");
|
|
|
|
// 5. Verify the file exists in the sandbox via the original connection
|
|
assert!(
|
|
env.file_exists("cp_test_upload.txt").await.unwrap(),
|
|
"uploaded file should exist in the sandbox"
|
|
);
|
|
let remote_content = env
|
|
.read_file("cp_test_upload.txt", None, None)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
remote_content.contains("hello from fabro cp e2e test"),
|
|
"expected uploaded content in sandbox, got: {remote_content}"
|
|
);
|
|
|
|
// 6. Download: retrieve the file back to local via the reconnected sandbox
|
|
let local_download = tmp.path().join("download.txt");
|
|
reconnected
|
|
.download_file_to_local("cp_test_upload.txt", &local_download)
|
|
.await
|
|
.expect("download_file_to_local should succeed");
|
|
|
|
let downloaded = std::fs::read(&local_download).unwrap();
|
|
assert_eq!(downloaded, upload_content);
|
|
|
|
// 7. Upload a binary file to test non-UTF-8 content
|
|
let binary_content: Vec<u8> = (0..=255).collect();
|
|
let local_binary = tmp.path().join("binary.bin");
|
|
std::fs::write(&local_binary, &binary_content).unwrap();
|
|
|
|
reconnected
|
|
.upload_file_from_local(&local_binary, "cp_test_binary.bin")
|
|
.await
|
|
.expect("binary upload should succeed");
|
|
|
|
let local_binary_dl = tmp.path().join("binary_dl.bin");
|
|
reconnected
|
|
.download_file_to_local("cp_test_binary.bin", &local_binary_dl)
|
|
.await
|
|
.expect("binary download should succeed");
|
|
|
|
let downloaded_binary = std::fs::read(&local_binary_dl).unwrap();
|
|
assert_eq!(
|
|
downloaded_binary, binary_content,
|
|
"binary round-trip should be exact"
|
|
);
|
|
|
|
// 9. Cleanup
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
|
|
async fn daytona_computer_use_browser_screenshot() {
|
|
use base64::Engine;
|
|
let config = DaytonaConfig {
|
|
snapshot: None,
|
|
skip_clone: true,
|
|
..DaytonaConfig::default()
|
|
};
|
|
let env = DaytonaSandbox::new(config, None, None, None, None, None, None, None)
|
|
.await
|
|
.expect("DAYTONA_API_KEY must be set");
|
|
env.initialize().await.unwrap();
|
|
|
|
// 1. Start the computer use desktop environment (Xvfb, xfce4, etc.)
|
|
let cu = env.computer_use().await.unwrap();
|
|
let start_resp = cu.start().await.expect("computer_use.start() failed");
|
|
eprintln!("Computer use started: {:?}", start_resp.message);
|
|
|
|
// 2. Find or install a browser
|
|
let check = env
|
|
.exec_command(
|
|
"which chromium || which chromium-browser || which google-chrome || echo NONE",
|
|
30_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
eprintln!("Browser check: {}", check.stdout.trim());
|
|
|
|
if check.stdout.trim() == "NONE" {
|
|
let install_result = env
|
|
.exec_command(
|
|
"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq chromium 2>&1",
|
|
180_000, None, None, None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
eprintln!(
|
|
"Browser install exit_code={:?}, last_line={}",
|
|
install_result.exit_code,
|
|
install_result.stdout.lines().last().unwrap_or("")
|
|
);
|
|
assert_eq!(install_result.exit_code, Some(0), "Chromium install failed");
|
|
}
|
|
|
|
let browser_bin = env
|
|
.exec_command(
|
|
"which chromium || which chromium-browser || which google-chrome",
|
|
10_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let browser = browser_bin.stdout.trim().to_string();
|
|
eprintln!("Using browser: {browser}");
|
|
|
|
// 3. Detect the DISPLAY that computer use started
|
|
let display_check = env
|
|
.exec_command(
|
|
"ps aux | grep Xvfb | grep -v grep | head -1",
|
|
10_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
eprintln!("Xvfb process: {}", display_check.stdout.trim());
|
|
|
|
// 4. Launch browser with setsid to fully detach, and log stderr
|
|
let launch_cmd = format!(
|
|
"DISPLAY=:0 setsid {browser} --no-sandbox --disable-gpu \
|
|
--window-size=1024,768 --window-position=0,0 \
|
|
https://example.com > /tmp/chrome_stdout.log 2>/tmp/chrome_stderr.log &\n\
|
|
sleep 2 && echo launched"
|
|
);
|
|
let launch_result = env
|
|
.exec_command(&launch_cmd, 30_000, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
eprintln!("Browser launch exit_code={:?}", launch_result.exit_code);
|
|
|
|
// 5. Wait for the page to load, then check if browser is running
|
|
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
|
|
|
|
let ps_check = env
|
|
.exec_command(
|
|
"ps aux | grep -i chrom | grep -v grep",
|
|
10_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
eprintln!("Chrome processes:\n{}", ps_check.stdout);
|
|
|
|
let stderr_check = env
|
|
.exec_command(
|
|
"cat /tmp/chrome_stderr.log 2>/dev/null | tail -20",
|
|
10_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
eprintln!("Chrome stderr:\n{}", stderr_check.stdout);
|
|
|
|
// 5. Take a screenshot via the Computer Use API
|
|
let screenshot = cu
|
|
.screenshot()
|
|
.take_full_screen()
|
|
.await
|
|
.expect("screenshot failed");
|
|
|
|
let b64_data = screenshot
|
|
.screenshot
|
|
.expect("screenshot response had no data");
|
|
eprintln!(
|
|
"Screenshot captured: {} bytes base64 ({} bytes decoded approx)",
|
|
b64_data.len(),
|
|
b64_data.len() * 3 / 4
|
|
);
|
|
assert!(!b64_data.is_empty(), "screenshot should not be empty");
|
|
|
|
// 6. Decode and save to /tmp for manual inspection
|
|
let png_bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(&b64_data)
|
|
.expect("base64 decode failed");
|
|
let output_path = "/tmp/daytona_browser_screenshot.png";
|
|
std::fs::write(output_path, &png_bytes).expect("failed to write screenshot");
|
|
eprintln!(
|
|
"Screenshot saved to {output_path} ({} bytes)",
|
|
png_bytes.len()
|
|
);
|
|
|
|
// 7. Cleanup
|
|
cu.stop().await.ok();
|
|
env.cleanup().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
|
|
async fn daytona_playwright_mcp_sandbox_transport() {
|
|
use fabro_agent::Sandbox;
|
|
|
|
// Create sandbox from daytona-medium (has Node.js + Chromium)
|
|
let config = DaytonaConfig {
|
|
snapshot: None,
|
|
skip_clone: true,
|
|
..DaytonaConfig::default()
|
|
};
|
|
let sandbox = DaytonaSandbox::new(config, None, None, None, None, None, None, None)
|
|
.await
|
|
.expect("DAYTONA_API_KEY must be set");
|
|
sandbox.initialize().await.unwrap();
|
|
|
|
// 1. Install Playwright MCP server and its browser
|
|
eprintln!("Installing @playwright/mcp and Chromium browser...");
|
|
let install = sandbox
|
|
.exec_command(
|
|
"npm install -g @playwright/mcp@latest 2>&1 && npx playwright install --with-deps chromium 2>&1",
|
|
300_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
eprintln!(
|
|
"Install exit_code={:?}, last_lines:\n{}",
|
|
install.exit_code,
|
|
install
|
|
.stdout
|
|
.lines()
|
|
.rev()
|
|
.take(5)
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
);
|
|
assert_eq!(install.exit_code, Some(0), "Playwright install failed");
|
|
|
|
// 2. Start the Playwright MCP server via the sandbox transport resolution path
|
|
let mcp_port = 3100u16;
|
|
let mcp_config = fabro_mcp::config::McpServerSettings {
|
|
name: "playwright".into(),
|
|
transport: fabro_mcp::config::McpTransport::Sandbox {
|
|
protocol: fabro_mcp::config::McpHttpProtocol::Sse,
|
|
command: vec![
|
|
"npx".into(),
|
|
"@playwright/mcp@latest".into(),
|
|
"--port".into(),
|
|
mcp_port.to_string(),
|
|
"--headless".into(),
|
|
"--browser".into(),
|
|
"chromium".into(),
|
|
],
|
|
port: mcp_port,
|
|
env: std::collections::HashMap::new(),
|
|
},
|
|
current_dir: None,
|
|
clear_env: false,
|
|
startup_timeout_secs: 30,
|
|
tool_timeout_secs: 120,
|
|
};
|
|
|
|
// Resolve the sandbox transport: start the server, get preview URL, rewrite to
|
|
// HTTP
|
|
let resolved = match &mcp_config.transport {
|
|
fabro_mcp::config::McpTransport::Sandbox {
|
|
protocol,
|
|
command,
|
|
port,
|
|
..
|
|
} => {
|
|
let (url, headers) = {
|
|
let cmd_str = shell::shell_join(command);
|
|
let inner =
|
|
format!("{cmd_str} > /tmp/mcp_server_stdout.log 2>/tmp/mcp_server_stderr.log");
|
|
let launch_script = format!(
|
|
"setsid \"$BASH\" -c {} </dev/null >/dev/null 2>&1 &\necho $!",
|
|
shell::shell_quote(&inner)
|
|
);
|
|
let launch_result = sandbox
|
|
.exec_command(&launch_script, 30_000, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
eprintln!("MCP server PID: {}", launch_result.stdout.trim());
|
|
|
|
// Wait for server to listen
|
|
let poll_cmd = format!(
|
|
"for i in $(seq 1 30); do ss -tln | grep -q ':{port} ' && echo ready && exit 0; sleep 1; done; echo timeout"
|
|
);
|
|
let poll_result = sandbox
|
|
.exec_command(&poll_cmd, 60_000, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
eprintln!("Server readiness: {}", poll_result.stdout.trim());
|
|
|
|
if poll_result.stdout.trim() != "ready" {
|
|
let stderr = sandbox
|
|
.exec_command(
|
|
"cat /tmp/mcp_server_stderr.log 2>/dev/null | tail -20",
|
|
10_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.map(|r| r.stdout)
|
|
.unwrap_or_default();
|
|
panic!("MCP server did not start on port {port}. stderr:\n{stderr}");
|
|
}
|
|
|
|
sandbox
|
|
.get_preview_url(*port)
|
|
.await
|
|
.unwrap()
|
|
.expect("sandbox should support preview URLs")
|
|
};
|
|
eprintln!("Preview URL: {url}");
|
|
|
|
let url = fabro_mcp::http_transport::sandbox_mcp_http_url(*protocol, &url).unwrap();
|
|
|
|
fabro_mcp::config::McpServerSettings {
|
|
name: mcp_config.name.clone(),
|
|
transport: fabro_mcp::config::McpTransport::Http {
|
|
protocol: *protocol,
|
|
url,
|
|
headers,
|
|
},
|
|
current_dir: mcp_config.current_dir.clone(),
|
|
clear_env: mcp_config.clear_env,
|
|
startup_timeout_secs: mcp_config.startup_timeout_secs,
|
|
tool_timeout_secs: mcp_config.tool_timeout_secs,
|
|
}
|
|
}
|
|
_ => unreachable!(),
|
|
};
|
|
|
|
// 3. Connect the MCP client to the resolved HTTP endpoint
|
|
let mut manager = fabro_mcp::connection_manager::McpConnectionManager::new();
|
|
let results = manager.start_servers(&[resolved]).await;
|
|
for (name, result) in &results {
|
|
match result {
|
|
Ok(count) => eprintln!("MCP server '{name}' ready with {count} tools"),
|
|
Err(e) => panic!("MCP server '{name}' failed: {e}"),
|
|
}
|
|
}
|
|
|
|
// 4. List the tools to verify we got Playwright tools
|
|
let tools = manager.all_tools();
|
|
eprintln!("Discovered {} MCP tools:", tools.len());
|
|
for (name, info) in tools {
|
|
eprintln!(
|
|
" - {name}: {}",
|
|
info.description.chars().take(80).collect::<String>()
|
|
);
|
|
}
|
|
assert!(!tools.is_empty(), "Should have discovered Playwright tools");
|
|
|
|
// 5. Install the browser via MCP tool (ensures correct version is available)
|
|
let install_tool = tools
|
|
.keys()
|
|
.find(|k| k.ends_with("browser_install"))
|
|
.expect("no browser_install tool found");
|
|
eprintln!("Calling tool: {install_tool}");
|
|
let install_result = manager.call_tool(install_tool, serde_json::json!({})).await;
|
|
match &install_result {
|
|
Ok(result) => eprintln!(
|
|
"Install result: {}",
|
|
result
|
|
.content
|
|
.first()
|
|
.map(|c| format!("{c:?}"))
|
|
.unwrap_or_default()
|
|
),
|
|
Err(e) => eprintln!("Install error (non-fatal): {e}"),
|
|
}
|
|
|
|
// 6. Call the browser_navigate tool to load a page
|
|
let nav_tool = tools
|
|
.keys()
|
|
.find(|k| k.ends_with("browser_navigate"))
|
|
.expect("no browser_navigate tool found");
|
|
eprintln!("Calling tool: {nav_tool}");
|
|
let nav_result = manager
|
|
.call_tool(nav_tool, serde_json::json!({"url": "https://example.com"}))
|
|
.await;
|
|
match &nav_result {
|
|
Ok(result) => eprintln!(
|
|
"Navigate result: {}",
|
|
&result
|
|
.content
|
|
.first()
|
|
.map(|c| format!("{c:?}"))
|
|
.unwrap_or_default()
|
|
),
|
|
Err(e) => eprintln!("Navigate error: {e}"),
|
|
}
|
|
assert!(nav_result.is_ok(), "Navigate should succeed");
|
|
|
|
// 7. Take a snapshot to verify the page loaded
|
|
let snap_tool = tools
|
|
.keys()
|
|
.find(|k| k.contains("snapshot"))
|
|
.expect("no snapshot tool found");
|
|
eprintln!("Calling tool: {snap_tool}");
|
|
let snap_result = manager.call_tool(snap_tool, serde_json::json!({})).await;
|
|
match &snap_result {
|
|
Ok(result) => {
|
|
let text = result
|
|
.content
|
|
.first()
|
|
.map(|c| format!("{c:?}"))
|
|
.unwrap_or_default();
|
|
eprintln!(
|
|
"Snapshot result (first 500 chars): {}",
|
|
&text[..text.len().min(500)]
|
|
);
|
|
assert!(
|
|
text.contains("Example Domain"),
|
|
"Snapshot should contain 'Example Domain'"
|
|
);
|
|
}
|
|
Err(e) => panic!("Snapshot failed: {e}"),
|
|
}
|
|
|
|
// 8. Cleanup
|
|
sandbox.cleanup().await.unwrap();
|
|
}
|