mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
A run now resolves a single author and committer identity once, after its
GitHub credentials are selected and before anything can commit, and uses it
for every commit it creates. Resolution order: a complete explicit
`run.git.author`; the run's GitHub App bot account
(`<slug>[bot] <id+slug[bot]@users.noreply.github.com>`); the authenticated
user of the run's PAT; the generic `Fabro <noreply@fabro.sh>`. A partial
explicit author overlays the fields it supplies. Only the selected
credential is consulted; a failed lookup is a setup error. A standalone
installation token falls back to the generic identity with a warning.
The resolved identity is carried on `RunOptions` and `EngineServices`,
recorded as a `git.identity.resolved` event and `RunProjection.git_identity`
so resume reuses it, and exposed through the run state API. Engine
checkpoints and metadata commits read it through `RunOptions::git_author`.
Every workflow execution path receives it as `GIT_AUTHOR_NAME`,
`GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL`, applied
last so it wins over inherited host variables and `[run.environment]`
entries: prepare steps, command stages, native agent shell tools, and ACP
launches. The identity is injected even without a Git origin, and the old
local `git config user.*` write is removed.
fabro-github gains `GET /user` and `/users/{slug}[bot]` lookups with mocked
tests for success, unauthorized, malformed, and transient cases. Real-Git
integration tests commit in the primary checkout, a clone, and a fresh
repository under conflicting local config, `[run.environment]`, and host
variables, and prove concurrent runs do not leak identities. CLI workflow
tests cover host script stages and ACP launch env through `fabro run`.
Docs and generated option metadata now describe the credential-derived
defaults instead of the stale `fabro`/`fabro@local` values.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1951 lines
68 KiB
Rust
1951 lines
68 KiB
Rust
//! Integration tests for the driver-backed Daytona sandbox.
|
|
//!
|
|
//! 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_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
|
use fabro_sandbox::{
|
|
CloneRequest, DaytonaCredentials, ProviderAccess, RunSandbox, SandboxProviderKind,
|
|
provider_sandbox,
|
|
};
|
|
use fabro_static::EnvVars;
|
|
use fabro_store::{ArtifactKey, ArtifactStore};
|
|
use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref};
|
|
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 sandbox_driver::{LifecycleTimers, Resources, SandboxSource, SandboxSpec};
|
|
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_at(
|
|
object_store,
|
|
"",
|
|
std::time::Duration::from_millis(1),
|
|
None,
|
|
&store_dir,
|
|
));
|
|
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.run_summary_store().list_all(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.run_summary_store().list_all(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 store_dir = test_store_dir(run_dir);
|
|
let object_store = Arc::new(LocalFileSystem::new_with_prefix(&store_dir)?);
|
|
let store = fabro_store::test_support::test_database_at(
|
|
object_store,
|
|
"",
|
|
std::time::Duration::from_millis(1),
|
|
None,
|
|
&store_dir,
|
|
);
|
|
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?)
|
|
}
|
|
|
|
/// Live credentials from the process environment, the way the vault would
|
|
/// supply them in production.
|
|
fn daytona_access(credentials: DaytonaCredentials) -> ProviderAccess {
|
|
ProviderAccess {
|
|
daytona: Some(credentials),
|
|
..ProviderAccess::default()
|
|
}
|
|
}
|
|
|
|
fn live_daytona_credentials() -> DaytonaCredentials {
|
|
let api_key = std::env::var(EnvVars::DAYTONA_API_KEY).expect("DAYTONA_API_KEY must be set");
|
|
DaytonaCredentials::from_api_key(api_key, |name| std::env::var(name).ok())
|
|
}
|
|
|
|
async fn create_env() -> RunSandbox {
|
|
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>,
|
|
) -> RunSandbox {
|
|
provider_sandbox(
|
|
SandboxProviderKind::DAYTONA,
|
|
&daytona_access(live_daytona_credentials()),
|
|
SandboxSpec::new(SandboxSource::HostDirectory),
|
|
&CloneRequest::default(),
|
|
github_app.as_ref(),
|
|
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_lossy().contains("hello"));
|
|
|
|
env.delete().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_lossy().trim().contains('2'));
|
|
|
|
env.delete().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!(matches!(
|
|
result.termination,
|
|
fabro_sandbox::Termination::Cancelled | fabro_sandbox::Termination::Killed
|
|
));
|
|
assert_eq!(result.stderr_lossy(), "Command cancelled");
|
|
|
|
env.delete().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_eq!(result.termination, fabro_sandbox::Termination::TimedOut);
|
|
assert_eq!(result.stderr_lossy(), "Command timed out locally");
|
|
|
|
env.delete().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_text(test_path).await.unwrap();
|
|
assert!(read_back.contains(content));
|
|
|
|
// Delete
|
|
env.delete_file(test_path).await.unwrap();
|
|
assert!(!env.file_exists(test_path).await.unwrap());
|
|
|
|
env.delete().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.delete().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
|
|
async fn daytona_snapshot_sandbox() {
|
|
let mut resources = Resources::default();
|
|
resources.cpu_cores = Some(2);
|
|
resources.memory_mb = Some(4096);
|
|
resources.disk_mb = Some(10_240);
|
|
let mut timers = LifecycleTimers::default();
|
|
timers.auto_stop_after_idle = Some(std::time::Duration::from_hours(1));
|
|
let spec = SandboxSpec::new(SandboxSource::Dockerfile {
|
|
content: "FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string(),
|
|
})
|
|
.resources(resources)
|
|
.timers(timers);
|
|
|
|
let creds = load_github_app_credentials();
|
|
let env = provider_sandbox(
|
|
SandboxProviderKind::DAYTONA,
|
|
&daytona_access(live_daytona_credentials()),
|
|
spec,
|
|
&CloneRequest::default(),
|
|
Some(&creds),
|
|
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_lossy().contains("ripgrep"));
|
|
|
|
env.delete().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_text(remote_path).await.unwrap();
|
|
assert!(
|
|
remote_content.len() > 100 * 1024,
|
|
"remote artifact should be >100KB, got {} bytes",
|
|
remote_content.len()
|
|
);
|
|
|
|
env.delete().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<RunSandbox> = 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,
|
|
git_identity: 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.delete().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: &RunSandbox) -> (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_lossy()
|
|
);
|
|
let base_sha = sha_result.stdout_lossy().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_lossy(),
|
|
checkout_result.stderr_lossy()
|
|
);
|
|
|
|
(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<RunSandbox> = 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.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_lossy()
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
git_identity: 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.delete().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<RunSandbox> = 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.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_lossy()
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
git_identity: 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_lossy());
|
|
let projection: fabro_store::RunProjection =
|
|
serde_json::from_slice(run_json.stdout_lossy().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_lossy().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.delete().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<RunSandbox> = 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,
|
|
git_identity: 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.delete().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
|
|
.ssh_access_command()
|
|
.await
|
|
.unwrap()
|
|
.expect("Daytona should offer an SSH command");
|
|
assert!(!ssh_command.is_empty(), "ssh_command should not be empty");
|
|
assert!(
|
|
ssh_command.contains("ssh"),
|
|
"ssh_command should contain 'ssh': {ssh_command}",
|
|
);
|
|
|
|
env.delete().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.ssh_access_command().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_lossy().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.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_lossy()
|
|
);
|
|
}
|
|
|
|
// 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_lossy().contains("fabro-sh/fabro"),
|
|
"origin should point to fabro-sh/fabro, got: {}",
|
|
result.stdout_lossy().trim()
|
|
);
|
|
|
|
env.delete().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 credentials = fabro_github::resolve_clone_credentials(
|
|
&fabro_github::GitHubContext::new(&creds, &fabro_github::github_api_base_url()),
|
|
"fabro-sh",
|
|
"fabro",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
credentials.username(),
|
|
"x-access-token",
|
|
"installed org repo should get credentials for pushing"
|
|
);
|
|
assert!(
|
|
!credentials.password().is_empty(),
|
|
"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<RunSandbox> = 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.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_lossy()
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
git_identity: 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_lossy()
|
|
);
|
|
assert!(
|
|
ls_result.stdout_lossy().contains(&branch_name),
|
|
"run branch should exist on origin after push, got: {}",
|
|
ls_result.stdout_lossy().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.success() {
|
|
eprintln!(
|
|
"Warning: failed to delete remote branch {branch_name}: {}",
|
|
r.stdout_lossy()
|
|
);
|
|
}
|
|
}
|
|
|
|
env.delete().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_lossy().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.delete().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_for_run;
|
|
use fabro_types::RunSandboxInstance;
|
|
|
|
// 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 access = ProviderAccess {
|
|
daytona: Some(live_daytona_credentials()),
|
|
..ProviderAccess::default()
|
|
};
|
|
let reconnected = reconnect_for_run(&record, &access, None, 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_text("cp_test_upload.txt").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.delete().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
|
|
async fn daytona_computer_use_browser_screenshot() {
|
|
let env = provider_sandbox(
|
|
SandboxProviderKind::DAYTONA,
|
|
&daytona_access(live_daytona_credentials()),
|
|
SandboxSpec::new(SandboxSource::HostDirectory),
|
|
&CloneRequest::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.) through the
|
|
// driver's VNC facet, which also signs a viewer URL.
|
|
let vnc = env
|
|
.handle()
|
|
.expect("initialized sandbox has a handle")
|
|
.vnc()
|
|
.expect("Daytona exposes VNC");
|
|
let connection = vnc.vnc_connection().await.expect("VNC connection failed");
|
|
eprintln!("VNC viewer: {}", connection.url);
|
|
assert!(connection.url.contains("vnc.html"));
|
|
|
|
// 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_lossy().trim());
|
|
|
|
if check.stdout_lossy().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_lossy().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_lossy().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_lossy().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_lossy());
|
|
|
|
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_lossy());
|
|
|
|
// 5. The desktop is serving: noVNC listens on its port.
|
|
let listening = env
|
|
.exec_command(
|
|
"ss -ltn 2>/dev/null | grep -q ':6080 ' || (command -v curl >/dev/null && curl -sf -o /dev/null http://127.0.0.1:6080/)",
|
|
10_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
listening.success(),
|
|
"noVNC should be reachable inside the sandbox"
|
|
);
|
|
|
|
// 7. Cleanup
|
|
env.delete().await.unwrap();
|
|
}
|
|
|
|
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
|
|
async fn daytona_playwright_mcp_sandbox_transport() {
|
|
// Create sandbox from daytona-medium (has Node.js + Chromium)
|
|
let sandbox = provider_sandbox(
|
|
SandboxProviderKind::DAYTONA,
|
|
&daytona_access(live_daytona_credentials()),
|
|
SandboxSpec::new(SandboxSource::HostDirectory),
|
|
&CloneRequest::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_lossy()
|
|
.lines()
|
|
.rev()
|
|
.take(5)
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
);
|
|
assert_eq!(install.exit_code, Some(0), "Playwright install failed");
|
|
|
|
// 2. The Playwright MCP server as an agent stage gets it: launched in the
|
|
// sandbox by pebble and reached over SSE through Daytona's preview link,
|
|
// token header included. A scripted model drives the tools, so the test is
|
|
// about the sandbox transport and nothing else.
|
|
let mcp_port = 3100u16;
|
|
let server = fabro_mcp::pebble::pebble_server(&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: 60,
|
|
tool_timeout_secs: 120,
|
|
});
|
|
let (client, _provider) = pebble_coding_agent::test_support::client_from(
|
|
pebble_coding_agent::test_support::ScriptedProvider::new(vec![
|
|
pebble_coding_agent::test_support::ScriptedCall::response(
|
|
pebble_coding_agent::test_support::tool_call_response(
|
|
"mcp__playwright__browser_install",
|
|
"install",
|
|
serde_json::json!({}),
|
|
),
|
|
),
|
|
pebble_coding_agent::test_support::ScriptedCall::response(
|
|
pebble_coding_agent::test_support::tool_call_response(
|
|
"mcp__playwright__browser_navigate",
|
|
"navigate",
|
|
serde_json::json!({"url": "https://example.com"}),
|
|
),
|
|
),
|
|
pebble_coding_agent::test_support::ScriptedCall::response(
|
|
pebble_coding_agent::test_support::tool_call_response(
|
|
"mcp__playwright__browser_snapshot",
|
|
"snapshot",
|
|
serde_json::json!({}),
|
|
),
|
|
),
|
|
pebble_coding_agent::test_support::ScriptedCall::response(
|
|
pebble_coding_agent::test_support::text_response("browsed"),
|
|
),
|
|
]),
|
|
);
|
|
let sandbox = Arc::new(sandbox);
|
|
let routes = sandbox
|
|
.port_routes()
|
|
.expect("Daytona forwards ports through preview URLs");
|
|
let mut agent = pebble_coding_agent::CodingAgent::builder(
|
|
client,
|
|
Arc::clone(&sandbox) as Arc<dyn pebble_coding_agent::environment::Environment>,
|
|
)
|
|
.model("test/model")
|
|
.permission_level(pebble_coding_agent::events::PermissionLevel::Full)
|
|
.mcp_servers([server])
|
|
.port_routes(routes)
|
|
.build()
|
|
.await
|
|
.expect("the agent builds with the sandbox-hosted server");
|
|
|
|
// 3. The server started and its tools are registered.
|
|
let statuses = agent.snapshot().mcp_servers().to_vec();
|
|
assert_eq!(statuses.len(), 1, "{statuses:?}");
|
|
assert_eq!(
|
|
statuses[0].error, None,
|
|
"the Playwright server should start: {statuses:?}"
|
|
);
|
|
eprintln!("Discovered {} MCP tools:", statuses[0].tools.len());
|
|
for tool in &statuses[0].tools {
|
|
eprintln!(" - {}", tool.name);
|
|
}
|
|
assert!(
|
|
statuses[0]
|
|
.tools
|
|
.iter()
|
|
.any(|tool| tool.name == "mcp__playwright__browser_navigate"),
|
|
"Should have discovered Playwright tools"
|
|
);
|
|
|
|
// 4. Install the browser, navigate, and snapshot through the agent.
|
|
let mut events = agent.subscribe();
|
|
let report = agent.prompt("browse example.com").await;
|
|
assert!(report.result.is_ok(), "{report:?}");
|
|
let mut completions = Vec::new();
|
|
while let Ok(event) = events.try_recv() {
|
|
if let pebble_coding_agent::events::CodingEvent::ToolCallCompleted {
|
|
tool_name,
|
|
output,
|
|
is_error,
|
|
..
|
|
} = event.event
|
|
{
|
|
completions.push((tool_name, output, is_error));
|
|
}
|
|
}
|
|
let navigate = completions
|
|
.iter()
|
|
.find(|(name, _, _)| name == "mcp__playwright__browser_navigate")
|
|
.expect("navigate ran");
|
|
assert!(!navigate.2, "Navigate should succeed: {navigate:?}");
|
|
let snapshot = completions
|
|
.iter()
|
|
.find(|(name, _, _)| name == "mcp__playwright__browser_snapshot")
|
|
.expect("snapshot ran");
|
|
assert!(!snapshot.2, "Snapshot should succeed: {snapshot:?}");
|
|
assert!(
|
|
snapshot.1.to_string().contains("Example Domain"),
|
|
"Snapshot should contain 'Example Domain'"
|
|
);
|
|
agent
|
|
.shutdown(pebble_coding_agent::ShutdownReason::Completed)
|
|
.await
|
|
.expect("the agent shuts down");
|
|
|
|
// 8. Cleanup
|
|
sandbox.delete().await.unwrap();
|
|
}
|