fabro/lib/apps/fabro-cli/tests/it/workflow/mod.rs
Bryan Helmkamp beac547a1f
Run the workflow scenarios on the Docker provider instead of the stdio plugins
The host_plugin_ and docker_plugin_ variants ran each scenario under
Fabro's old plugin transport with the provider kinds `host` and
`docker-plugin`, which Petri's Fabro frontend rejects. Under Petri every
provider is already served by a sandbox-driver plugin, so those variants
test nothing distinct. A single docker_ variant replaces them: an
environment with provider `docker` on buildpack-deps:noble, created on
an isolated server, skipping without the sandbox-driver-docker
executable or a daemon with the image unless
FABRO_REQUIRE_SANDBOX_PLUGINS is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 18:20:24 -04:00

221 lines
7.2 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.
///
/// Petri serves each provider through the matching sandbox-driver plugin
/// executable on `PATH`. The `docker` variant skips without
/// `sandbox-driver-docker` or without a Docker daemon that has the image,
/// unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set, as CI sets it.
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),
}
}