fabro/lib/apps/fabro-cli/tests/it/workflow/mod.rs
Scott Werner 6ac6d5495f fix: run built-in sandbox providers in process
Register lazy Host, Docker, and Daytona factories for Petri execution,
fork, and prune. Share server provider configuration, preserve lease
fingerprints, and source Daytona credentials from the vault.

Remove built-in plugin setup and skip gates; add a release-mode worker
and prune regression to catch the failure that blocked nightly builds.

Co-Authored-By: Codex <noreply@openai.com>
2026-09-24 17:14:47 -04:00

220 lines
7.1 KiB
Rust

#![allow(
clippy::absolute_paths,
reason = "This test module prefers explicit type paths over extra imports."
)]
mod agent_linear;
mod artifacts;
mod command_agent_mixed;
mod command_pipeline;
mod command_routing;
mod conditional_branching;
pub(super) mod docker;
mod dry_run_examples;
mod full_stack;
mod hooks;
mod human_gate;
use std::path::{Path, PathBuf};
use std::time::Duration;
use fabro_test::{TestContext, expect_reqwest_status};
use fabro_types::RunStreamItem;
use serde_json::Value;
use crate::cmd::support::{RunProjection, server_endpoint};
pub(super) fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/it/workflow/fixtures")
.join(name)
}
pub(super) fn read_conclusion(run_dir: &Path) -> Value {
serde_json::to_value(
run_state(run_dir)
.conclusion
.expect("run store conclusion should exist"),
)
.expect("conclusion should serialize")
}
pub(super) fn read_run_spec(run_dir: &Path) -> Value {
serde_json::to_value(run_state(run_dir).spec).expect("run spec should serialize")
}
/// The nodes whose stages succeeded, in the order they first ran.
pub(super) fn completed_nodes(run_dir: &Path) -> Vec<String> {
let state = run_state(run_dir);
let mut nodes = state
.iter_stages()
.filter(|(_, stage)| stage.state == fabro_types::StageState::Succeeded)
.map(|(stage_id, _)| stage_id.node_id().to_string())
.collect::<Vec<_>>();
nodes.dedup();
nodes
}
pub(super) fn has_event(run_dir: &Path, event_name: &str) -> bool {
run_stream_items(run_dir)
.into_iter()
.any(|item| item.name() == Some(event_name))
}
pub(super) fn dump_export(context: &TestContext, run_id: &str) -> PathBuf {
let output_dir = context.temp_dir.join(format!("dump-{run_id}"));
context
.command()
.args([
"dump",
"--output",
output_dir
.to_str()
.expect("dump output path should be valid UTF-8"),
run_id,
])
.assert()
.success();
output_dir
}
#[expect(
clippy::disallowed_methods,
reason = "integration test helpers inspect exported files synchronously"
)]
pub(super) fn stage_dump_dir(export_dir: &Path, stage_id: &str) -> PathBuf {
let stages_dir = export_dir.join("stages");
let mut matches: Vec<_> = std::fs::read_dir(&stages_dir)
.unwrap_or_else(|err| panic!("reading {} should succeed: {err}", stages_dir.display()))
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name == stage_id || name.split_once('-').is_some_and(|(_, id)| id == stage_id)
})
})
.collect();
matches.sort();
match matches.as_slice() {
[path] => path.clone(),
[] => panic!(
"stage dump dir for {stage_id} not found in {}",
stages_dir.display()
),
_ => panic!("stage dump dir for {stage_id} was ambiguous: {matches:?}"),
}
}
/// Find the single run directory for this test context.
pub(super) fn find_run_dir(context: &TestContext) -> PathBuf {
context.single_run_dir()
}
pub(super) fn run_id_for(run_dir: &Path) -> String {
infer_run_id(run_dir)
}
fn infer_run_id(run_dir: &Path) -> String {
run_dir
.file_name()
.map(|name| name.to_string_lossy().to_string())
.and_then(|name| name.rsplit('-').next().map(ToOwned::to_owned))
.filter(|value| !value.is_empty())
.expect("run directory name should contain run id suffix")
}
fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build")
.block_on(future)
}
async fn get_server_json_for_storage<T: serde::de::DeserializeOwned>(
storage_dir: &Path,
path: &str,
) -> T {
let (client, base_url) = server_endpoint(storage_dir).expect("server endpoint should exist");
let response = client
.get(format!("{base_url}{path}"))
.send()
.await
.expect("server request should succeed");
let response =
expect_reqwest_status(response, fabro_http::StatusCode::OK, format!("GET {path}")).await;
response
.json::<T>()
.await
.expect("server response should parse")
}
fn run_state(run_dir: &Path) -> RunProjection {
let run_id = infer_run_id(run_dir);
let runs_dir = run_dir.parent().expect("run dir should have parent");
let storage_dir = runs_dir.parent().expect("runs dir should have parent");
block_on(get_server_json_for_storage(
storage_dir,
&format!("/api/v1/runs/{run_id}/state"),
))
}
fn run_stream_items(run_dir: &Path) -> Vec<RunStreamItem> {
let run_id = infer_run_id(run_dir);
let runs_dir = run_dir.parent().expect("run dir should have parent");
let storage_dir = runs_dir.parent().expect("runs dir should have parent");
let response: serde_json::Value = block_on(get_server_json_for_storage(
storage_dir,
&format!("/api/v1/runs/{run_id}/events?after=0&limit=1000"),
));
crate::support::parse_stream_items(&response)
}
/// Runs a scenario against every sandbox provider fabro supports:
///
/// - `local`: the host provider, the session server's own `local` environment.
/// - `daytona`: the Daytona provider, live credentials required.
/// - `docker`: the Docker provider, an environment on `buildpack-deps:noble`
/// created on an isolated server.
///
/// The built-in providers run in process. The `docker` variant skips when
/// no Docker daemon has the required image, unless CI requires the backend
/// with `FABRO_REQUIRE_SANDBOX_BACKENDS`.
macro_rules! sandbox_tests {
($name:ident) => {
sandbox_tests!($name, keys = []);
};
($name:ident, keys = [$($key:expr),* $(,)?]) => {
paste::paste! {
#[fabro_macros::e2e_test($(live($key)),*)]
fn [<local_ $name>]() {
[<scenario_ $name>](&fabro_test::test_context!(), "local");
}
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY") $(, live($key))*)]
fn [<daytona_ $name>]() {
[<scenario_ $name>](&fabro_test::test_context!(), "daytona");
}
#[fabro_macros::e2e_test($(live($key)),*)]
fn [<docker_ $name>]() {
let mut context = fabro_test::test_context!();
if let Some(environment) = $crate::workflow::docker::configure(&mut context) {
$crate::workflow::docker::run_with_server_log(&context, || {
[<scenario_ $name>](&context, environment);
});
}
}
}
};
}
pub(super) use sandbox_tests;
pub(super) fn timeout_for(sandbox: &str) -> Duration {
match sandbox {
"daytona" => Duration::from_mins(10),
docker::ENVIRONMENT => Duration::from_mins(5),
_ => Duration::from_mins(3),
}
}