#![expect( clippy::disallowed_methods, reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path" )] use std::path::PathBuf; use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use predicates::prelude::*; use super::support::{created_run_id, run_state}; #[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 server_storage_root(settings: &serde_json::Value) -> &str { settings .get("server") .and_then(|server| server.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_toml_fixture() -> &'static str { r#" _version = 1 [server.auth] methods = ["dev-token"] [server.storage] root = "/srv/fabro-server" [run.model] name = "server-model" provider = "openai" [run.inputs] server_only = "1" shared = "server" "# } fn resolved_server_settings_fixture() -> serde_json::Value { let settings = fabro_config::ServerSettingsBuilder::from_toml(server_settings_toml_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.environment] id = "cli" [environments.cli] provider = "daytona" [environments.cli.env] CLI_ONLY = "1" SHARED = "cli" [environments.cli.labels] cli_only = "1" shared = "cli" [server.auth] methods = ["dev-token"] "#, ); let project = tempfile::tempdir().expect("project fixture directory should create"); std::fs::create_dir_all(project.path().join(".fabro")) .expect("project .fabro directory should create"); 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" "#, ) .expect("project config fixture should write"); let workflow_dir = project.path().join(".fabro").join("workflows").join("demo"); std::fs::create_dir_all(&workflow_dir).expect("workflow fixture directory should create"); 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.environment] id = "run" [environments.run] provider = "daytona" [environments.run.env] RUN_ONLY = "1" SHARED = "run" [environments.run.labels] run_only = "1" shared = "run" "#, ) .expect("workflow fixture config should write"); std::fs::write( project.path().join("standalone.fabro"), "digraph Test { start -> end }", ) .expect("standalone workflow fixture should write"); 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.auth] methods = ["dev-token"] [server.storage] root = "{}" [run.execution] approval = "auto" [[run.prepare.steps]] script = "cli-setup" "#, storage_dir.display() ), ); let project = tempfile::tempdir().expect("external workflow fixture directory should create"); std::fs::create_dir_all(project.path().join(".fabro")) .expect("external workflow .fabro directory should create"); std::fs::write( project.path().join(".fabro/project.toml"), r#" _version = 1 [[run.prepare.steps]] script = "project-setup" "#, ) .expect("external workflow project config should write"); std::fs::write( project.path().join("workflow.fabro"), r#" digraph Test { start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] start -> exit } "#, ) .expect("external workflow graph fixture should write"); 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" "#, ) .expect("external workflow config should write"); (project, storage_dir) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[test] fn create_explicit_workflow_warns_without_transmitting_machine_or_project_run_settings() { let mut context = test_context!(); let (project, storage_dir) = setup_external_workflow_fixture(&mut context); context.ensure_home_server_auth_methods(); 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 create = context .command() .env_remove("FABRO_STORAGE_DIR") .current_dir(cwd.path()) .args([ "create", "--dry-run", "--model", "gpt-5.4-pro", "--environment", "local", workflow.to_str().unwrap(), ]) .assert() .success(); let stderr = String::from_utf8_lossy(&create.get_output().stderr); assert!(stderr.contains("settings.toml contains run"), "{stderr}"); assert!(stderr.contains("project.toml contains run"), "{stderr}"); assert!( stderr.contains("do not transmit these settings"), "{stderr}" ); assert!(stderr.contains("workflow.toml"), "{stderr}"); let run_id = created_run_id(create.get_output()); 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_spec = serde_json::to_value(&state.spec).unwrap(); assert_eq!( run_spec["settings"]["run"]["execution"]["approval"].as_str(), // The CLI did not transmit its machine setting. The in-process server // may still apply its own active configuration independently. Some("auto") ); assert_eq!( run_spec["settings"]["run"]["model"]["name"].as_str(), Some("gpt-5.4-pro") ); // run.prepare.steps replaces the whole ordered list across layers. A // `script` step serializes with the `type` discriminator that preserves the // script-vs-argv distinction in the run spec wire shape. assert_eq!( run_spec["settings"]["run"]["prepare"]["steps"], serde_json::json!([{ "type": "script", "script": "workflow-setup" }]) ); } #[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_flag() { let context = test_context!(); context .settings() .arg("--local") .assert() .failure() .stderr(predicate::str::contains( "unexpected argument '--local' found", )); } #[test] fn settings_rejects_workflow_argument() { let context = test_context!(); context .settings() .arg("demo") .assert() .failure() .stderr(predicate::str::contains("unexpected argument 'demo' found")); } #[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"); 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" [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_eq!( cfg["user"]["cli"]["output"]["verbosity"].as_str(), Some("verbose") ); assert!(cfg["user"].get("features").is_none()); assert_eq!( cfg["server"]["server"]["auth"]["methods"][0].as_str(), Some("dev-token") ); assert_eq!(server_storage_root(&cfg), "/srv/fabro-server"); assert_eq!( cfg["server"]["server"]["artifacts"]["store"]["type"].as_str(), Some("local") ); assert!(cfg.get("run").is_none()); assert!(cfg.get("project").is_none()); } #[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"); 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"); then.status(200) .header("Content-Type", "application/json") .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_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")), ); }