mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
The TypeScript client's `RunCheckpoint` still carried the legacy executor's resume fields; regenerating it from the spec gives it the slim shape (`timestamp`, `current_node`, `git_commit_sha`). The CLI test helpers that read a run's stream from its directory or the API are named `run_stream_items`, so nothing but the dropped table's migration history is still called `run_events`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
236 lines
7.8 KiB
Rust
236 lines
7.8 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;
|
|
mod dry_run_examples;
|
|
mod full_stack;
|
|
mod hooks;
|
|
mod human_gate;
|
|
pub(super) mod plugin;
|
|
|
|
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 bundled Host provider in-process.
|
|
/// - `daytona`: the bundled Daytona provider, live credentials required.
|
|
/// - `host-plugin`: the driver's Host executable over stdio under the
|
|
/// non-bundled `host` kind, a clone-based managed workspace.
|
|
/// - `docker-plugin`: the driver's Docker executable over stdio under the
|
|
/// non-bundled `docker-plugin` kind.
|
|
///
|
|
/// The plugin variants need the executables `cargo` builds for
|
|
/// `fabro-sandbox`; without them (or without a Docker daemon) they skip,
|
|
/// 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 [<host_plugin_ $name>]() {
|
|
let mut context = fabro_test::test_context!();
|
|
if let Some(environment) =
|
|
$crate::workflow::plugin::configure(&mut context, $crate::workflow::plugin::Plugin::Host)
|
|
{
|
|
$crate::workflow::plugin::run_with_server_log(&context, || {
|
|
[<scenario_ $name>](&context, environment);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[fabro_macros::e2e_test($(live($key)),*)]
|
|
fn [<docker_plugin_ $name>]() {
|
|
let mut context = fabro_test::test_context!();
|
|
if let Some(environment) =
|
|
$crate::workflow::plugin::configure(&mut context, $crate::workflow::plugin::Plugin::Docker)
|
|
{
|
|
$crate::workflow::plugin::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-plugin" => Duration::from_mins(5),
|
|
_ => Duration::from_mins(3),
|
|
}
|
|
}
|