mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
Make `fabro settings` render dense resolved settings by default for local inspection, add a resolved view to the server settings endpoint with an explicit compatibility marker, and update tests plus generated API clients to lock the new behavior.
998 lines
26 KiB
Rust
998 lines
26 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use fabro_config::parse_settings_layer;
|
|
use fabro_test::{fabro_snapshot, test_context};
|
|
use fabro_types::settings::SettingsLayer;
|
|
use httpmock::MockServer;
|
|
use predicates::prelude::*;
|
|
|
|
use super::support::run_state;
|
|
use crate::support::unique_run_id;
|
|
|
|
#[test]
|
|
fn old_config_show_command_is_rejected() {
|
|
let context = test_context!();
|
|
let mut cmd = context.command();
|
|
cmd.args(["config", "show"]);
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: false
|
|
exit_code: 2
|
|
----- stdout -----
|
|
----- stderr -----
|
|
error: unrecognized subcommand 'config'
|
|
|
|
Usage: fabro [OPTIONS] <COMMAND>
|
|
|
|
For more information, try '--help'.
|
|
");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn parse_settings(stdout: &[u8]) -> serde_json::Value {
|
|
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML settings")
|
|
}
|
|
|
|
fn parse_settings_json(stdout: &[u8]) -> serde_json::Value {
|
|
serde_json::from_slice(stdout).expect("stdout should be valid JSON settings")
|
|
}
|
|
|
|
fn run_goal_inline(settings: &serde_json::Value) -> Option<&str> {
|
|
let goal = settings.get("run")?.get("goal")?;
|
|
(goal.get("type")?.as_str() == Some("inline"))
|
|
.then(|| goal.get("value")?.as_str())
|
|
.flatten()
|
|
}
|
|
|
|
fn run_model_name(settings: &serde_json::Value) -> Option<&str> {
|
|
settings.get("run")?.get("model")?.get("name")?.as_str()
|
|
}
|
|
|
|
fn run_model_provider(settings: &serde_json::Value) -> Option<&str> {
|
|
settings.get("run")?.get("model")?.get("provider")?.as_str()
|
|
}
|
|
|
|
fn run_inputs(settings: &serde_json::Value) -> &serde_json::Map<String, serde_json::Value> {
|
|
settings
|
|
.get("run")
|
|
.and_then(|run| run.get("inputs"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("run.inputs")
|
|
}
|
|
|
|
fn run_sandbox(settings: &serde_json::Value) -> &serde_json::Value {
|
|
settings
|
|
.get("run")
|
|
.and_then(|run| run.get("sandbox"))
|
|
.expect("run.sandbox")
|
|
}
|
|
|
|
fn run_checkpoint(settings: &serde_json::Value) -> &serde_json::Value {
|
|
settings
|
|
.get("run")
|
|
.and_then(|run| run.get("checkpoint"))
|
|
.expect("run.checkpoint")
|
|
}
|
|
|
|
fn run_hooks(settings: &serde_json::Value) -> &[serde_json::Value] {
|
|
settings
|
|
.get("run")
|
|
.and_then(|run| run.get("hooks"))
|
|
.and_then(serde_json::Value::as_array)
|
|
.expect("run.hooks")
|
|
}
|
|
|
|
fn run_agent_mcps(settings: &serde_json::Value) -> &serde_json::Map<String, serde_json::Value> {
|
|
settings
|
|
.get("run")
|
|
.and_then(|run| run.get("agent"))
|
|
.and_then(|agent| agent.get("mcps"))
|
|
.and_then(serde_json::Value::as_object)
|
|
.expect("run.agent.mcps")
|
|
}
|
|
|
|
fn auto_approve_enabled(settings: &serde_json::Value) -> bool {
|
|
settings
|
|
.get("run")
|
|
.and_then(|run| run.get("execution"))
|
|
.and_then(|execution| execution.get("approval"))
|
|
.and_then(serde_json::Value::as_str)
|
|
== Some("auto")
|
|
}
|
|
|
|
fn run_prepare_commands(settings: &serde_json::Value) -> Vec<String> {
|
|
settings
|
|
.get("run")
|
|
.and_then(|run| run.get("prepare"))
|
|
.and_then(|prepare| prepare.get("commands"))
|
|
.and_then(serde_json::Value::as_array)
|
|
.expect("run.prepare.commands")
|
|
.iter()
|
|
.map(|value| {
|
|
value
|
|
.as_str()
|
|
.expect("command should be a string")
|
|
.to_string()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn server_storage_root(settings: &serde_json::Value) -> &str {
|
|
settings
|
|
.get("server")
|
|
.and_then(|server| server.get("storage"))
|
|
.and_then(|storage| storage.get("root"))
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("server.storage.root")
|
|
}
|
|
|
|
fn server_settings_layer_fixture() -> SettingsLayer {
|
|
parse_settings_layer(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "/srv/fabro-server"
|
|
|
|
[run.model]
|
|
name = "server-model"
|
|
provider = "openai"
|
|
|
|
[run.inputs]
|
|
server_only = "1"
|
|
shared = "server"
|
|
"#,
|
|
)
|
|
.expect("server settings fixture should parse")
|
|
}
|
|
|
|
fn resolved_server_settings_fixture() -> serde_json::Value {
|
|
let settings = fabro_config::resolve(&server_settings_layer_fixture())
|
|
.expect("server settings fixture should resolve");
|
|
serde_json::to_value(settings).expect("resolved settings payload should serialize")
|
|
}
|
|
|
|
fn server_settings_body(settings: &serde_json::Value) -> String {
|
|
serde_json::to_string(settings).expect("settings payload should serialize")
|
|
}
|
|
|
|
/// Set up home config and project config for settings command tests.
|
|
/// Uses `context.home_dir` for the home directory. Returns project tempdir.
|
|
fn setup_settings_fixture(context: &fabro_test::TestContext) -> tempfile::TempDir {
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.output]
|
|
verbosity = "verbose"
|
|
|
|
[run.model]
|
|
name = "cli-model"
|
|
provider = "openai"
|
|
|
|
[run.inputs]
|
|
cli_only = "1"
|
|
shared = "cli"
|
|
|
|
[run.checkpoint]
|
|
exclude_globs = ["cli-only", "shared"]
|
|
|
|
[[run.hooks]]
|
|
id = "shared"
|
|
name = "shared"
|
|
event = "run_start"
|
|
script = "echo cli"
|
|
|
|
[run.agent.mcps.shared]
|
|
type = "stdio"
|
|
command = ["echo", "cli"]
|
|
|
|
[run.sandbox]
|
|
provider = "daytona"
|
|
|
|
[run.sandbox.env]
|
|
CLI_ONLY = "1"
|
|
SHARED = "cli"
|
|
|
|
[run.sandbox.daytona]
|
|
|
|
[run.sandbox.daytona.labels]
|
|
cli_only = "1"
|
|
shared = "cli"
|
|
"#,
|
|
);
|
|
|
|
let project = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(project.path().join(".fabro")).unwrap();
|
|
std::fs::write(
|
|
project.path().join(".fabro/project.toml"),
|
|
r#"
|
|
_version = 1
|
|
|
|
[run.model]
|
|
name = "project-model"
|
|
|
|
[run.inputs]
|
|
project_only = "1"
|
|
shared = "project"
|
|
|
|
[[run.hooks]]
|
|
id = "project"
|
|
name = "project"
|
|
event = "run_complete"
|
|
script = "echo project"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let workflow_dir = project.path().join(".fabro").join("workflows").join("demo");
|
|
std::fs::create_dir_all(&workflow_dir).unwrap();
|
|
std::fs::write(
|
|
workflow_dir.join("workflow.toml"),
|
|
r#"
|
|
_version = 1
|
|
|
|
[run]
|
|
goal = "demo goal"
|
|
|
|
[run.model]
|
|
name = "run-model"
|
|
provider = "anthropic"
|
|
|
|
[run.inputs]
|
|
run_only = "1"
|
|
shared = "run"
|
|
|
|
[run.checkpoint]
|
|
exclude_globs = ["run-only", "shared"]
|
|
|
|
[[run.hooks]]
|
|
id = "shared"
|
|
name = "shared"
|
|
event = "run_start"
|
|
script = "echo run"
|
|
|
|
[[run.hooks]]
|
|
id = "run-only"
|
|
name = "run-only"
|
|
event = "run_complete"
|
|
script = "echo run-only"
|
|
|
|
[run.agent.mcps.shared]
|
|
type = "stdio"
|
|
command = ["echo", "run"]
|
|
|
|
[run.agent.mcps.run_only]
|
|
type = "stdio"
|
|
command = ["echo", "run-only"]
|
|
|
|
[run.sandbox.env]
|
|
RUN_ONLY = "1"
|
|
SHARED = "run"
|
|
|
|
[run.sandbox.daytona]
|
|
|
|
[run.sandbox.daytona.labels]
|
|
run_only = "1"
|
|
shared = "run"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
project.path().join("standalone.fabro"),
|
|
"digraph Test { start -> end }",
|
|
)
|
|
.unwrap();
|
|
|
|
project
|
|
}
|
|
|
|
/// Set up an external workflow fixture with a custom storage_dir in
|
|
/// settings.toml. Returns (project_tempdir, storage_dir_path).
|
|
fn setup_external_workflow_fixture(
|
|
context: &mut fabro_test::TestContext,
|
|
) -> (tempfile::TempDir, PathBuf) {
|
|
let storage_dir = context.home_dir.join("fabro-data");
|
|
context.manage_storage_dir(&storage_dir);
|
|
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.storage]
|
|
root = "{}"
|
|
|
|
[run.execution]
|
|
approval = "auto"
|
|
|
|
[[run.prepare.steps]]
|
|
script = "cli-setup"
|
|
"#,
|
|
storage_dir.display()
|
|
),
|
|
);
|
|
|
|
let project = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(project.path().join(".fabro")).unwrap();
|
|
std::fs::write(
|
|
project.path().join(".fabro/project.toml"),
|
|
r#"
|
|
_version = 1
|
|
|
|
[[run.prepare.steps]]
|
|
script = "project-setup"
|
|
|
|
[run.sandbox]
|
|
preserve = true
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
project.path().join("workflow.fabro"),
|
|
r#"
|
|
digraph Test {
|
|
start [shape=Mdiamond, label="Start"]
|
|
exit [shape=Msquare, label="Exit"]
|
|
start -> exit
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
project.path().join("workflow.toml"),
|
|
r#"
|
|
_version = 1
|
|
|
|
[workflow]
|
|
graph = "workflow.fabro"
|
|
|
|
[run]
|
|
goal = "Ship it"
|
|
|
|
[run.model]
|
|
name = "claude-sonnet-4-6"
|
|
|
|
[[run.prepare.steps]]
|
|
script = "workflow-setup"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
(project, storage_dir)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn settings_local_merges_cli_and_project_defaults() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
|
|
let output = context
|
|
.settings()
|
|
.arg("--local")
|
|
.current_dir(project.path())
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
|
|
let cfg = parse_settings(&output);
|
|
assert!(cfg.get("_version").is_none());
|
|
assert_eq!(cfg["project"]["directory"].as_str(), Some("."));
|
|
assert_eq!(cfg["workflow"]["graph"].as_str(), Some("workflow.fabro"));
|
|
assert_eq!(cfg["run"]["execution"]["approval"].as_str(), Some("prompt"));
|
|
assert_eq!(cfg["run"]["sandbox"]["provider"].as_str(), Some("daytona"));
|
|
assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model"));
|
|
assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai"));
|
|
assert_eq!(run_goal_inline(&cfg).as_deref(), None);
|
|
|
|
// v2 R22: run.inputs replaces the inherited map wholesale rather than
|
|
// merging by key, so the project layer wipes out the CLI layer's inputs.
|
|
let vars = run_inputs(&cfg);
|
|
assert_eq!(
|
|
vars.get("project_only").and_then(serde_json::Value::as_str),
|
|
Some("1")
|
|
);
|
|
assert_eq!(
|
|
vars.get("shared").and_then(serde_json::Value::as_str),
|
|
Some("project")
|
|
);
|
|
assert!(
|
|
!vars.contains_key("cli_only"),
|
|
"run.inputs should replace across layers, not merge by key"
|
|
);
|
|
|
|
// v2 R71: provider-native maps such as run.sandbox.daytona.labels remain
|
|
// sticky merge-by-key, so CLI labels persist under the project layer.
|
|
let sandbox = run_sandbox(&cfg);
|
|
let labels = &sandbox["daytona"]["labels"];
|
|
assert_eq!(labels["cli_only"].as_str(), Some("1"));
|
|
assert_eq!(labels["shared"].as_str(), Some("cli"));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
|
|
let output = context
|
|
.settings()
|
|
.current_dir(project.path())
|
|
.args(["--local", "demo"])
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
|
|
let cfg = parse_settings(&output);
|
|
assert_eq!(run_goal_inline(&cfg).as_deref(), Some("demo goal"));
|
|
assert_eq!(run_model_name(&cfg).as_deref(), Some("run-model"));
|
|
assert_eq!(run_model_provider(&cfg).as_deref(), Some("anthropic"));
|
|
|
|
// v2 R22: run.inputs replaces wholesale, so the workflow layer wins
|
|
// over project and cli.
|
|
let vars = run_inputs(&cfg);
|
|
assert_eq!(vars.get("run_only").and_then(|v| v.as_str()), Some("1"));
|
|
assert_eq!(vars.get("shared").and_then(|v| v.as_str()), Some("run"));
|
|
|
|
// checkpoint.exclude_globs is a security/policy list: replace by default.
|
|
let checkpoint = run_checkpoint(&cfg);
|
|
assert_eq!(
|
|
checkpoint["exclude_globs"],
|
|
serde_json::json!(["run-only", "shared"])
|
|
);
|
|
|
|
// Hooks: id-based replacement. The "shared" hook appears in both cli and
|
|
// workflow layers and resolves to the workflow entry; project and run-only
|
|
// contribute the other two ids.
|
|
let hooks = run_hooks(&cfg);
|
|
assert!(hooks.len() >= 2);
|
|
let shared_hook = hooks
|
|
.iter()
|
|
.find(|hook| hook["name"].as_str() == Some("shared"))
|
|
.expect("shared hook");
|
|
assert_eq!(shared_hook["command"].as_str(), Some("echo run"));
|
|
assert!(
|
|
hooks
|
|
.iter()
|
|
.any(|hook| hook["name"].as_str() == Some("run-only"))
|
|
);
|
|
|
|
let mcps = run_agent_mcps(&cfg);
|
|
let shared = mcps.get("shared").expect("shared mcp");
|
|
assert_eq!(shared["transport"]["type"].as_str(), Some("stdio"));
|
|
assert_eq!(
|
|
shared["transport"]["command"],
|
|
serde_json::json!(["echo", "run"])
|
|
);
|
|
assert!(mcps.contains_key("run_only"));
|
|
|
|
// run.sandbox.daytona.labels stays sticky merge-by-key per R71.
|
|
let sandbox = run_sandbox(&cfg);
|
|
let labels = &sandbox["daytona"]["labels"];
|
|
assert_eq!(labels["run_only"].as_str(), Some("1"));
|
|
assert_eq!(labels["shared"].as_str(), Some("run"));
|
|
|
|
// run.sandbox.env stays sticky merge-by-key per R71.
|
|
let env = &sandbox["env"];
|
|
assert_eq!(env["CLI_ONLY"].as_str(), Some("1"));
|
|
assert_eq!(env["RUN_ONLY"].as_str(), Some("1"));
|
|
assert_eq!(env["SHARED"].as_str(), Some("run"));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_local_explicit_workflow_path_uses_workflow_project_layers() {
|
|
let mut context = test_context!();
|
|
let (project, _storage_dir) = setup_external_workflow_fixture(&mut context);
|
|
let cwd = tempfile::tempdir().unwrap();
|
|
let workflow = project.path().join("workflow.toml");
|
|
|
|
// Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from settings.toml
|
|
let output = context
|
|
.settings()
|
|
.env_remove("FABRO_STORAGE_DIR")
|
|
.current_dir(cwd.path())
|
|
.args(["--local", workflow.to_str().unwrap()])
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
|
|
let cfg = parse_settings(&output);
|
|
assert!(auto_approve_enabled(&cfg));
|
|
// v2 R30: run.prepare.steps replaces the whole ordered list across layers.
|
|
// The highest-precedence layer (workflow) wins.
|
|
assert_eq!(run_prepare_commands(&cfg), vec![
|
|
"workflow-setup".to_string()
|
|
]);
|
|
assert_eq!(run_sandbox(&cfg)["preserve"].as_bool(), Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
|
|
let mut context = test_context!();
|
|
let (project, storage_dir) = setup_external_workflow_fixture(&mut context);
|
|
let cwd = tempfile::tempdir().unwrap();
|
|
let workflow = project.path().join("workflow.toml");
|
|
let run_id = unique_run_id();
|
|
|
|
// Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from settings.toml
|
|
context
|
|
.command()
|
|
.env_remove("FABRO_STORAGE_DIR")
|
|
.current_dir(cwd.path())
|
|
.args([
|
|
"create",
|
|
"--dry-run",
|
|
"--model",
|
|
"gpt-5.2",
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
workflow.to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
let runs_dir = storage_dir.join("scratch");
|
|
let run_dir = std::fs::read_dir(&runs_dir)
|
|
.unwrap()
|
|
.flatten()
|
|
.map(|entry| entry.path())
|
|
.find(|path| {
|
|
path.is_dir()
|
|
&& path
|
|
.file_name()
|
|
.is_some_and(|name| name.to_string_lossy().ends_with(&run_id))
|
|
})
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"expected run directory for {run_id} under {}",
|
|
runs_dir.display()
|
|
)
|
|
});
|
|
|
|
let state = run_state(&run_dir);
|
|
let run_record =
|
|
serde_json::to_value(state.run.as_ref().expect("run record should exist")).unwrap();
|
|
assert_eq!(
|
|
run_record["settings"]["run"]["execution"]["approval"].as_str(),
|
|
Some("auto")
|
|
);
|
|
assert_eq!(
|
|
run_record["settings"]["server"]["storage"]["root"].as_str(),
|
|
Some(storage_dir.to_str().unwrap())
|
|
);
|
|
assert_eq!(
|
|
run_record["settings"]["run"]["sandbox"]["preserve"].as_bool(),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
run_record["settings"]["run"]["model"]["name"].as_str(),
|
|
Some("gpt-5.2")
|
|
);
|
|
// v2 R30: run.prepare.steps replaces the whole ordered list across layers.
|
|
assert_eq!(
|
|
run_record["settings"]["run"]["prepare"]["steps"],
|
|
serde_json::json!([{"script": "workflow-setup"}])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_fabro_path_matches_ambient_defaults() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
|
|
let ambient = context
|
|
.settings()
|
|
.arg("--local")
|
|
.current_dir(project.path())
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
let graph = context
|
|
.settings()
|
|
.current_dir(project.path())
|
|
.args(["--local", "standalone.fabro"])
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
|
|
assert_eq!(parse_settings(&graph), parse_settings(&ambient));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_missing_run_config_errors() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
|
|
let mut cmd = context.settings();
|
|
cmd.current_dir(project.path());
|
|
cmd.args(["--local", "missing.toml"]);
|
|
let output = cmd.output().expect("command should execute");
|
|
assert!(!output.status.success());
|
|
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("workflow not found:"),
|
|
"stderr should report missing workflow path, got:\n{stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("missing.toml"),
|
|
"stderr should include missing workflow filename, got:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_legacy_cli_config_warns_and_ignores_it() {
|
|
let context = test_context!();
|
|
let project = tempfile::tempdir().unwrap();
|
|
|
|
context.write_home(
|
|
".fabro/cli.toml",
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.output]
|
|
verbosity = "verbose"
|
|
|
|
[run.model]
|
|
name = "legacy-model"
|
|
"#,
|
|
);
|
|
|
|
let assert = context
|
|
.settings()
|
|
.arg("--local")
|
|
.current_dir(project.path())
|
|
.assert()
|
|
.success()
|
|
.stderr(predicate::str::contains("ignoring legacy config file"))
|
|
.stderr(predicate::str::contains("Rename it to"));
|
|
|
|
let cfg = parse_settings(&assert.get_output().stdout);
|
|
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal"));
|
|
assert!(
|
|
cfg["run"]["model"].get("name").is_none(),
|
|
"resolved dense settings should omit an unset run.model.name"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_user_config_wins_over_legacy_cli_config() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
context.write_home(
|
|
".fabro/cli.toml",
|
|
r#"
|
|
_version = 1
|
|
|
|
[run.model]
|
|
name = "legacy-model"
|
|
|
|
[run.inputs]
|
|
shared = "legacy"
|
|
"#,
|
|
);
|
|
|
|
let assert = context
|
|
.settings()
|
|
.arg("--local")
|
|
.current_dir(project.path())
|
|
.assert()
|
|
.success()
|
|
.stderr(predicate::str::contains("ignoring legacy config file"));
|
|
|
|
let cfg = parse_settings(&assert.get_output().stdout);
|
|
assert_eq!(run_model_name(&cfg).as_deref(), Some("project-model"));
|
|
let vars = run_inputs(&cfg);
|
|
assert_eq!(
|
|
vars.get("shared").and_then(serde_json::Value::as_str),
|
|
Some("project")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_uses_fabro_home_for_home_config_resolution() {
|
|
let context = test_context!();
|
|
let fabro_home = tempfile::tempdir().unwrap();
|
|
|
|
std::fs::write(
|
|
fabro_home.path().join("settings.toml"),
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.output]
|
|
verbosity = "verbose"
|
|
|
|
[run.model]
|
|
name = "from-fabro-home"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let output = context
|
|
.settings()
|
|
.args(["--local", "--json"])
|
|
.env("FABRO_HOME", fabro_home.path())
|
|
.env_remove("FABRO_STORAGE_DIR")
|
|
.output()
|
|
.expect("command should execute");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"settings command failed:\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
|
|
let cfg = parse_settings_json(&output.stdout);
|
|
assert!(cfg.get("_version").is_none());
|
|
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("verbose"));
|
|
assert_eq!(
|
|
cfg["run"]["model"]["name"].as_str(),
|
|
Some("from-fabro-home")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_rejects_server_url_flag() {
|
|
let context = test_context!();
|
|
context
|
|
.command()
|
|
.args(["--server-url", "https://cli.example.com", "settings"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"unexpected argument '--server-url' found",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_rejects_storage_dir_flag() {
|
|
let context = test_context!();
|
|
context
|
|
.settings()
|
|
.args(["--storage-dir", "/tmp/fabro-settings"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"unexpected argument '--storage-dir' found",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_rejects_local_and_server_combination() {
|
|
let context = test_context!();
|
|
context
|
|
.settings()
|
|
.args(["--local", "--server", "https://cli.example.com"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"the argument '--local' cannot be used with '--server <SERVER>'",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_rejects_workflow_without_local() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
|
|
context
|
|
.settings()
|
|
.current_dir(project.path())
|
|
.arg("demo")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"WORKFLOW requires --local; use `fabro settings --local WORKFLOW`",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_fetches_server_resolved_settings() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
let server = MockServer::start();
|
|
let server_settings = resolved_server_settings_fixture();
|
|
let mock = server.mock(|when, then| {
|
|
when.method("GET")
|
|
.path("/api/v1/settings")
|
|
.query_param("view", "resolved");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.header("X-Fabro-Settings-View", "resolved")
|
|
.body(server_settings_body(&server_settings));
|
|
});
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!(
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
url = "{}/api/v1"
|
|
|
|
[cli.output]
|
|
verbosity = "verbose"
|
|
|
|
[run.model]
|
|
name = "cli-model"
|
|
provider = "openai"
|
|
|
|
[run.inputs]
|
|
cli_only = "1"
|
|
shared = "cli"
|
|
"#,
|
|
server.base_url()
|
|
),
|
|
);
|
|
|
|
let output = context
|
|
.settings()
|
|
.current_dir(project.path())
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
|
|
mock.assert();
|
|
let cfg = parse_settings(&output);
|
|
assert!(cfg.get("_version").is_none());
|
|
assert_eq!(cfg["project"]["directory"].as_str(), Some("."));
|
|
assert_eq!(cfg["workflow"]["graph"].as_str(), Some("workflow.fabro"));
|
|
assert_eq!(cfg["run"]["execution"]["approval"].as_str(), Some("prompt"));
|
|
assert_eq!(run_model_name(&cfg).as_deref(), Some("server-model"));
|
|
assert_eq!(run_model_provider(&cfg).as_deref(), Some("openai"));
|
|
assert_eq!(server_storage_root(&cfg), "/srv/fabro-server");
|
|
assert_eq!(cfg["cli"]["output"]["verbosity"].as_str(), Some("normal"));
|
|
|
|
// Server-backed mode now returns the selected server's own dense resolved
|
|
// settings; local project/user overlays are not merged into the output.
|
|
let vars = run_inputs(&cfg);
|
|
assert_eq!(
|
|
vars.get("server_only").and_then(serde_json::Value::as_str),
|
|
Some("1")
|
|
);
|
|
assert_eq!(
|
|
vars.get("shared").and_then(serde_json::Value::as_str),
|
|
Some("server")
|
|
);
|
|
assert!(
|
|
!vars.contains_key("project_only"),
|
|
"server-backed settings output must not include local workflow/project overlays"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_cli_server_target_overrides_configured_server_target() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
let configured_server = MockServer::start();
|
|
let configured_mock = configured_server.mock(|when, then| {
|
|
when.method("GET")
|
|
.path("/api/v1/settings")
|
|
.query_param("view", "resolved");
|
|
then.status(500)
|
|
.body("configured-server-should-not-be-used");
|
|
});
|
|
let cli_server = MockServer::start();
|
|
let cli_server_settings = resolved_server_settings_fixture();
|
|
let cli_mock = cli_server.mock(|when, then| {
|
|
when.method("GET")
|
|
.path("/api/v1/settings")
|
|
.query_param("view", "resolved");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.header("X-Fabro-Settings-View", "resolved")
|
|
.body(server_settings_body(&cli_server_settings));
|
|
});
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!(
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
url = "{}/api/v1"
|
|
|
|
[cli.output]
|
|
verbosity = "verbose"
|
|
"#,
|
|
configured_server.base_url()
|
|
),
|
|
);
|
|
|
|
let output = context
|
|
.settings()
|
|
.current_dir(project.path())
|
|
.args(["--server", &format!("{}/api/v1", cli_server.base_url())])
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
|
|
cli_mock.assert();
|
|
configured_mock.assert_calls(0);
|
|
let cfg = parse_settings(&output);
|
|
assert_eq!(server_storage_root(&cfg), "/srv/fabro-server");
|
|
}
|
|
|
|
#[test]
|
|
fn settings_errors_when_server_lacks_resolved_view_marker() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
let server = MockServer::start();
|
|
let server_settings = resolved_server_settings_fixture();
|
|
let mock = server.mock(|when, then| {
|
|
when.method("GET")
|
|
.path("/api/v1/settings")
|
|
.query_param("view", "resolved");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(server_settings_body(&server_settings));
|
|
});
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!(
|
|
r#"
|
|
_version = 1
|
|
|
|
[cli.target]
|
|
type = "http"
|
|
url = "{}/api/v1"
|
|
"#,
|
|
server.base_url()
|
|
),
|
|
);
|
|
|
|
context
|
|
.settings()
|
|
.current_dir(project.path())
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"server does not support resolved settings view; upgrade the server or use --local",
|
|
));
|
|
|
|
mock.assert();
|
|
}
|
|
|
|
#[test]
|
|
fn settings_unreachable_http_target_fails_clearly() {
|
|
let context = test_context!();
|
|
let project = setup_settings_fixture(&context);
|
|
|
|
context
|
|
.settings()
|
|
.current_dir(project.path())
|
|
.args(["--server", "http://127.0.0.1:9"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("retrieve_server_settings")
|
|
.or(predicate::str::contains("error sending request")),
|
|
);
|
|
}
|