mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
325 lines
10 KiB
Rust
325 lines
10 KiB
Rust
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "integration test initializes an isolated git repository with the system git binary"
|
|
)]
|
|
|
|
use fabro_acp::test_support::fake_acp_agent_script;
|
|
use fabro_config::Storage;
|
|
use fabro_test::test_context;
|
|
use fabro_types::EventBody;
|
|
use fabro_vault::{SecretType, Vault};
|
|
|
|
use super::{find_run_dir, has_event, read_conclusion, run_events, run_id_for, run_state};
|
|
use crate::cmd::support::output_stdout;
|
|
|
|
#[test]
|
|
fn acp_backend_workflow() {
|
|
let mut context = test_context!();
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
"[server.auth]\nmethods = [\"dev-token\"]\n",
|
|
);
|
|
context.isolated_server();
|
|
let fake_agent = write_fake_acp_agent(&context);
|
|
let acp_config = fake_acp_config_attr(&fake_agent);
|
|
let workflow = context.temp_dir.join("acp_backend.fabro");
|
|
context.write_temp(
|
|
"acp_backend.fabro",
|
|
format!(
|
|
r#"digraph ACP {{
|
|
graph [goal="Exercise ACP backend"]
|
|
start [shape=Mdiamond]
|
|
work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}]
|
|
exit [shape=Msquare]
|
|
start -> work
|
|
work -> exit
|
|
}}"#
|
|
),
|
|
);
|
|
init_git_repo(&context.temp_dir);
|
|
|
|
context
|
|
.run_cmd()
|
|
.args(["--auto-approve", "--environment", "local"])
|
|
.arg(&workflow)
|
|
.assert()
|
|
.success();
|
|
|
|
let run_dir = find_run_dir(&context);
|
|
let conclusion = read_conclusion(&run_dir);
|
|
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
|
|
|
|
let events = run_events(&run_dir);
|
|
assert!(has_event(&run_dir, "agent.acp.started"));
|
|
assert!(has_event(&run_dir, "agent.acp.completed"));
|
|
let completed = events
|
|
.iter()
|
|
.find_map(|event| match &event.event.body {
|
|
EventBody::StageCompleted(props) if event.event.node_id.as_deref() == Some("work") => {
|
|
Some(props)
|
|
}
|
|
_ => None,
|
|
})
|
|
.expect("work stage should complete");
|
|
assert_eq!(completed.response.as_deref(), Some("hello from acp"));
|
|
assert!(
|
|
completed
|
|
.files_touched
|
|
.iter()
|
|
.any(|file| file == "hello.txt"),
|
|
"files_touched should include hello.txt: {:?}",
|
|
completed.files_touched
|
|
);
|
|
|
|
let state = serde_json::to_value(run_state(&run_dir)).expect("run state should serialize");
|
|
let stages = state["stages"]
|
|
.as_object()
|
|
.expect("run state should contain stages");
|
|
assert!(
|
|
stages.values().any(|stage| {
|
|
stage["provider_used"]["mode"] == "acp"
|
|
&& stage["provider_used"]["provider"] == "acp"
|
|
&& stage["provider_used"]["model"] == "fake"
|
|
&& stage["provider_used"].get("reasoning_effort").is_none()
|
|
&& stage["provider_used"].get("speed").is_none()
|
|
}),
|
|
"run projection should include ACP model usage metadata: {stages:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn acp_backend_does_not_inject_registered_provider_credentials() {
|
|
let mut context = test_context!();
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
"[server.auth]\nmethods = [\"dev-token\"]\n",
|
|
);
|
|
context.isolated_server();
|
|
seed_anthropic_vault(&context.storage_dir);
|
|
|
|
let fake_agent = write_fake_acp_agent(&context);
|
|
let env_record = context.temp_dir.join("acp-env.json");
|
|
let acp_config = fake_acp_config_attr_recording_env(&fake_agent, &env_record);
|
|
let workflow = context.temp_dir.join("acp_provider_env.fabro");
|
|
context.write_temp(
|
|
"acp_provider_env.fabro",
|
|
format!(
|
|
r#"digraph ACP {{
|
|
graph [goal="Exercise ACP backend with stored provider credentials"]
|
|
start [shape=Mdiamond]
|
|
work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}]
|
|
exit [shape=Msquare]
|
|
start -> work
|
|
work -> exit
|
|
}}"#
|
|
),
|
|
);
|
|
init_git_repo(&context.temp_dir);
|
|
|
|
context
|
|
.run_cmd()
|
|
.env_remove("ANTHROPIC_API_KEY")
|
|
.env_remove("OPENAI_API_KEY")
|
|
.env_remove("GEMINI_API_KEY")
|
|
.args(["--auto-approve", "--environment", "local"])
|
|
.arg(&workflow)
|
|
.assert()
|
|
.success();
|
|
|
|
let run_dir = find_run_dir(&context);
|
|
let conclusion = read_conclusion(&run_dir);
|
|
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
|
|
|
|
let recorded_env: serde_json::Value = serde_json::from_str(
|
|
&std::fs::read_to_string(&env_record)
|
|
.expect("fake ACP agent should record its environment"),
|
|
)
|
|
.expect("fake ACP environment record should be valid JSON");
|
|
assert_eq!(recorded_env, serde_json::json!({}));
|
|
}
|
|
|
|
#[test]
|
|
fn acp_artifacts_are_listed_when_touched_file_mtime_precedes_attempt_start() {
|
|
let mut context = test_context!();
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
"[server.auth]\nmethods = [\"dev-token\"]\n",
|
|
);
|
|
context.isolated_server();
|
|
let fake_agent = write_fake_acp_agent(&context);
|
|
let acp_config = fake_acp_config_attr_with_env(&fake_agent, vec![
|
|
serde_json::json!({
|
|
"name": "ACP_WRITE_PATH",
|
|
"value": "verification-artifacts/report.md",
|
|
}),
|
|
serde_json::json!({
|
|
"name": "ACP_WRITE_CONTENT",
|
|
"value": "verified\n",
|
|
}),
|
|
serde_json::json!({
|
|
"name": "ACP_WRITE_MTIME_EPOCH",
|
|
"value": "946684800",
|
|
}),
|
|
]);
|
|
context.write_temp(
|
|
"acp_artifact_collection.fabro",
|
|
format!(
|
|
r#"digraph ACPArtifacts {{
|
|
graph [goal="Capture verification artifacts"]
|
|
start [shape=Mdiamond]
|
|
verify [type="agent", backend="acp", prompt="write verification artifact", acp.config={acp_config}]
|
|
exit [shape=Msquare]
|
|
start -> verify
|
|
verify -> exit
|
|
}}"#
|
|
),
|
|
);
|
|
context.write_temp(
|
|
"workflow.toml",
|
|
r#"_version = 1
|
|
|
|
[workflow]
|
|
graph = "acp_artifact_collection.fabro"
|
|
|
|
[run]
|
|
goal = "Capture verification artifacts"
|
|
|
|
[run.checkpoint]
|
|
exclude_globs = ["verification-artifacts/**"]
|
|
|
|
[run.artifacts]
|
|
include = ["verification-artifacts/**"]
|
|
"#,
|
|
);
|
|
init_git_repo(&context.temp_dir);
|
|
|
|
context
|
|
.run_cmd()
|
|
.args(["--auto-approve", "--environment", "local"])
|
|
.arg(context.temp_dir.join("workflow.toml"))
|
|
.assert()
|
|
.success();
|
|
|
|
let run_dir = find_run_dir(&context);
|
|
let run_id = run_id_for(&run_dir);
|
|
let events = run_events(&run_dir);
|
|
let completed = events
|
|
.iter()
|
|
.find_map(|event| match &event.event.body {
|
|
EventBody::StageCompleted(props)
|
|
if event.event.node_id.as_deref() == Some("verify") =>
|
|
{
|
|
Some(props)
|
|
}
|
|
_ => None,
|
|
})
|
|
.expect("verify stage should complete");
|
|
assert!(
|
|
completed
|
|
.files_touched
|
|
.iter()
|
|
.any(|file| file == "verification-artifacts/report.md"),
|
|
"files_touched should include the artifact path: {:?}",
|
|
completed.files_touched
|
|
);
|
|
|
|
let output = context
|
|
.command()
|
|
.args(["--json", "artifact", "list", &run_id])
|
|
.output()
|
|
.expect("artifact list should execute");
|
|
assert!(
|
|
output.status.success(),
|
|
"artifact list failed\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let artifacts: Vec<serde_json::Value> =
|
|
serde_json::from_str(&output_stdout(&output)).expect("artifact list JSON should parse");
|
|
assert!(
|
|
artifacts.iter().any(|artifact| {
|
|
artifact["node_slug"] == "verify"
|
|
&& artifact["relative_path"] == "verification-artifacts/report.md"
|
|
}),
|
|
"artifact list should include the touched verification artifact: {artifacts:?}"
|
|
);
|
|
|
|
let completed_run = events
|
|
.iter()
|
|
.find_map(|event| match &event.event.body {
|
|
EventBody::RunCompleted(props) => Some(props),
|
|
_ => None,
|
|
})
|
|
.expect("run.completed should be emitted");
|
|
assert!(
|
|
completed_run.artifact_count > 0,
|
|
"run.completed should report captured artifacts"
|
|
);
|
|
}
|
|
|
|
fn write_fake_acp_agent(context: &fabro_test::TestContext) -> std::path::PathBuf {
|
|
context.write_temp("fake_acp_agent.py", fake_acp_agent_script());
|
|
context.temp_dir.join("fake_acp_agent.py")
|
|
}
|
|
|
|
fn fake_acp_config_attr(script_path: &std::path::Path) -> String {
|
|
fake_acp_config_attr_with_env(script_path, Vec::new())
|
|
}
|
|
|
|
fn fake_acp_config_attr_recording_env(
|
|
script_path: &std::path::Path,
|
|
env_record: &std::path::Path,
|
|
) -> String {
|
|
fake_acp_config_attr_with_env(script_path, vec![
|
|
serde_json::json!({"name": "ACP_ENV_RECORD", "value": env_record.to_string_lossy()}),
|
|
serde_json::json!({
|
|
"name": "ACP_ENV_RECORD_KEYS",
|
|
"value": "ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY",
|
|
}),
|
|
])
|
|
}
|
|
|
|
fn fake_acp_config_attr_with_env(
|
|
script_path: &std::path::Path,
|
|
extra_env: Vec<serde_json::Value>,
|
|
) -> String {
|
|
let mut env = vec![serde_json::json!({"name": "ACP_MODE", "value": "write_file"})];
|
|
env.extend(extra_env);
|
|
|
|
let config = serde_json::json!({
|
|
"type": "stdio",
|
|
"name": "fake",
|
|
"command": "python3",
|
|
"args": [script_path.to_string_lossy()],
|
|
"env": env,
|
|
})
|
|
.to_string();
|
|
format!("{config:?}")
|
|
}
|
|
|
|
fn seed_anthropic_vault(storage_dir: &std::path::Path) {
|
|
let mut vault =
|
|
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
|
|
vault
|
|
.set(
|
|
"ANTHROPIC_API_KEY",
|
|
"vault-anthropic-key",
|
|
SecretType::Token,
|
|
None,
|
|
)
|
|
.expect("Anthropic credential should store in test vault");
|
|
}
|
|
|
|
fn init_git_repo(dir: &std::path::Path) {
|
|
let output = std::process::Command::new("git")
|
|
.args(["init", "-q"])
|
|
.current_dir(dir)
|
|
.output()
|
|
.expect("git init should run");
|
|
assert!(
|
|
output.status.success(),
|
|
"git init failed\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|