fabro/lib/apps/fabro-server/tests/it/helpers.rs
Bryan Helmkamp 75e5f34c0d
Expose lithos request and response shapes through the API, server, CLI, and web
The OpenAPI spec adopts the lithos request, response, content part,
tool, usage, and cost schemas. The completions endpoint returns the
lithos `Response` JSON verbatim and SSE carries lithos `StreamEvent`s
verbatim. The models and providers endpoints serve the fabro-types
catalog views, and the install and model-test flows probe providers
through fabro-llm.

The CLI builds its catalog from the operator overlay, drives `fabro exec`
through the server gateway adapter, and parses reasoning effort with the
shared controls. The web app reads content parts as lithos-tagged
objects. The TypeScript client is regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:26:57 -06:00

321 lines
9.8 KiB
Rust

use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use fabro_config::{RunEnvironmentLayer, RunLayer, ServerSettingsBuilder};
use fabro_server::server::{AppState, spawn_scheduler};
use fabro_server::test_support::{
TestAppStateBuilder, build_test_router, llm_overlay_with_provider_base_url,
test_app_state as server_test_app_state, test_app_state_with_runtime_settings_and_env_lookup,
test_app_state_with_runtime_settings_and_options_and_registry_factory,
};
use fabro_test::{
assert_axum_status, assert_reqwest_status, expect_axum_json, expect_axum_status,
expect_axum_status_in, expect_axum_text,
};
use fabro_types::ServerSettings;
use tokio::time::sleep;
use tower::ServiceExt;
pub(crate) const MINIMAL_DOT: &str = r#"digraph Test {
graph [goal="Test"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
pub(crate) const POLL_INTERVAL: Duration = Duration::from_millis(10);
pub(crate) const POLL_ATTEMPTS: usize = 500;
#[derive(Clone)]
pub(crate) struct TestAppSettings {
pub server_settings: ServerSettings,
pub manifest_run_defaults: RunLayer,
}
impl Default for TestAppSettings {
fn default() -> Self {
settings_from_toml("_version = 1\n")
}
}
fn ensure_test_auth_methods(document: &mut toml::Table) {
let server = document
.entry("server")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.expect("[server] should stay a table in test fixtures");
let auth = server
.entry("auth")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.expect("[server.auth] should stay a table in test fixtures");
auth.entry("methods")
.or_insert_with(|| toml::Value::Array(vec![toml::Value::String("dev-token".to_string())]));
}
pub(crate) fn settings_from_toml(source: &str) -> TestAppSettings {
let mut document: toml::Table = source.parse().expect("test fixture should parse as TOML");
ensure_test_auth_methods(&mut document);
let manifest_run_defaults = document
.remove("run")
.map(toml::Value::try_into::<RunLayer>)
.transpose()
.expect("test run settings should parse")
.unwrap_or_default();
let server_settings = ServerSettingsBuilder::from_toml(
&toml::to_string(&document).expect("test fixture should serialize"),
)
.expect("test server settings should resolve");
TestAppSettings {
server_settings,
manifest_run_defaults,
}
}
pub(crate) fn test_app_state() -> Arc<AppState> {
server_test_app_state()
}
pub(crate) fn test_app_state_with_options(
settings: TestAppSettings,
max_concurrent_runs: usize,
) -> Arc<AppState> {
test_app_state_with_runtime_settings_and_options_and_registry_factory(
settings.server_settings,
settings.manifest_run_defaults,
max_concurrent_runs,
|interviewer| fabro_workflow::handler::default_registry(interviewer, || None),
)
}
pub(crate) fn test_settings() -> TestAppSettings {
TestAppSettings {
manifest_run_defaults: RunLayer {
environment: Some(RunEnvironmentLayer {
id: Some("local".to_string()),
..RunEnvironmentLayer::default()
}),
..RunLayer::default()
},
..TestAppSettings::default()
}
}
pub(crate) fn test_app_with_scheduler(state: Arc<AppState>) -> axum::Router {
spawn_scheduler(Arc::clone(&state));
build_test_router(state)
}
pub(crate) fn test_app_with_no_providers() -> axum::Router {
let settings = test_settings();
let state = test_app_state_with_runtime_settings_and_env_lookup(
settings.server_settings,
settings.manifest_run_defaults,
5,
|_| None,
);
build_test_router(state)
}
pub(crate) fn test_app_with_mock_anthropic(mock_base_url: &str) -> axum::Router {
let settings = test_settings();
let state = TestAppStateBuilder::new()
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
.max_concurrent_runs(5)
.llm_overlay(llm_overlay_with_provider_base_url(
"anthropic",
mock_base_url,
))
.vault_entries([("ANTHROPIC_API_KEY", "test-key")])
.build();
build_test_router(state)
}
pub(crate) fn api(path: &str) -> String {
format!("/api/v1{path}")
}
pub(crate) fn repo_root() -> PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.expect("fabro-server crate should be nested under lib/apps/fabro-server")
.to_path_buf()
}
#[expect(
clippy::disallowed_methods,
reason = "test fixture reads tracked files synchronously"
)]
pub(crate) fn read_repo_file(relative_path: &str) -> String {
let path = repo_root().join(relative_path);
std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()))
}
pub(crate) async fn body_json(body: Body) -> serde_json::Value {
let bytes = to_bytes(body, usize::MAX)
.await
.expect("response body should fit in memory");
serde_json::from_slice(&bytes).expect("response body should be valid JSON")
}
pub(crate) async fn response_status(
response: axum::response::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) {
assert_axum_status(response, expected, context).await;
}
pub(crate) async fn response_json(
response: axum::response::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) -> serde_json::Value {
expect_axum_json(response, expected, context).await
}
pub(crate) async fn response_text(
response: axum::response::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) -> String {
expect_axum_text(response, expected, context).await
}
pub(crate) async fn checked_response(
response: axum::response::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) -> axum::response::Response {
expect_axum_status(response, expected, context).await
}
pub(crate) async fn checked_response_in(
response: axum::response::Response,
expected: &[StatusCode],
context: impl std::fmt::Display,
) -> axum::response::Response {
expect_axum_status_in(response, expected, context).await
}
pub(crate) async fn reqwest_status(
response: fabro_http::Response,
expected: StatusCode,
context: impl std::fmt::Display,
) {
assert_reqwest_status(response, expected, context).await;
}
pub(crate) async fn create_and_start_run_from_manifest(
app: &axum::Router,
manifest: serde_json::Value,
) -> String {
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&manifest).expect("manifest fixture should serialize"),
))
.expect("create-run request should build");
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = body["id"]
.as_str()
.expect("create-run response should include an id")
.to_string();
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/start")))
.body(Body::empty())
.expect("start-run request should build");
response_status(
app.clone().oneshot(req).await.unwrap(),
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/start"),
)
.await;
run_id
}
pub(crate) fn minimal_manifest_json(dot_source: &str) -> serde_json::Value {
serde_json::json!({
"version": 1,
"cwd": "/tmp",
"target": {
"path": "workflow.fabro"
},
"workflows": {
"workflow.fabro": {
"source": dot_source,
"files": {}
}
}
})
}
pub(crate) fn minimal_manifest_json_with_dry_run(dot_source: &str) -> serde_json::Value {
let mut manifest = minimal_manifest_json(dot_source);
manifest["args"] = serde_json::json!({ "dry_run": true });
manifest
}
pub(crate) async fn run_json(app: &axum::Router, run_id: &str) -> serde_json::Value {
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}")))
.body(Body::empty())
.expect("run lookup request should build");
let response = app.clone().oneshot(req).await.unwrap();
response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}"),
)
.await
}
pub(crate) async fn wait_for_run_status(
app: &axum::Router,
run_id: &str,
expected: &[&str],
) -> String {
for _ in 0..POLL_ATTEMPTS {
let body = run_json(app, run_id).await;
let status = body["lifecycle"]["status"]["kind"]
.as_str()
.expect("run response should include a tagged status kind")
.to_string();
if expected.iter().any(|candidate| *candidate == status) {
return status;
}
sleep(POLL_INTERVAL).await;
}
panic!("run {run_id} did not reach any of {expected:?}");
}
pub(crate) async fn wait_for_run_status_not_in(
app: &axum::Router,
run_id: &str,
unexpected: &[&str],
) -> String {
for _ in 0..POLL_ATTEMPTS {
let body = run_json(app, run_id).await;
let status = body["lifecycle"]["status"]["kind"]
.as_str()
.expect("run response should include a tagged status kind")
.to_string();
if unexpected.iter().all(|candidate| *candidate != status) {
return status;
}
sleep(POLL_INTERVAL).await;
}
panic!("run {run_id} stayed in {unexpected:?}");
}