mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
When `fabro run` or `fabro create` omits `--environment` and the server has no environment named `default`, the CLI previously failed with only "could not retrieve environment `default`". It now lists the server's environment catalog in the error so the user can pass an explicit `--environment <id>` or create the missing `default` entry. Explicit `--environment` lookups keep their existing not-found message. Adds `Client::list_environments` to fabro-client for the catalog read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1635 lines
52 KiB
Rust
1635 lines
52 KiB
Rust
#![expect(
|
||
clippy::disallowed_methods,
|
||
reason = "integration tests stage temporary workflow and Git fixtures with synchronous APIs"
|
||
)]
|
||
|
||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
|
||
use httpmock::{HttpMockResponse, Mock, MockServer};
|
||
use insta::assert_snapshot;
|
||
use serde_json::json;
|
||
|
||
use super::support::{
|
||
created_run_id, environment_json, fixture, mock_environment,
|
||
mock_workflow_version_registrations, mock_workflow_version_registrations_recording,
|
||
output_stderr, output_stdout, remote_run_summary_json, resolve_run, run_count_for_test_case,
|
||
run_git, run_state,
|
||
};
|
||
use crate::support::unique_run_id;
|
||
|
||
fn resolved_run(settings: &fabro_types::WorkflowSettings) -> fabro_types::settings::RunNamespace {
|
||
settings.run.clone()
|
||
}
|
||
|
||
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
|
||
let status = match status {
|
||
"submitted" => json!({ "kind": "submitted" }),
|
||
other => panic!("unsupported test status {other:?}"),
|
||
};
|
||
remote_run_summary_json(
|
||
run_id,
|
||
"Test Workflow",
|
||
"test-workflow",
|
||
"Test run",
|
||
&status,
|
||
"2026-04-05T12:00:00Z",
|
||
)
|
||
}
|
||
|
||
fn mock_intent_create<'a>(
|
||
server: &'a MockServer,
|
||
run_id: &str,
|
||
requests: Arc<Mutex<Vec<serde_json::Value>>>,
|
||
) -> Mock<'a> {
|
||
let response = run_status_response(run_id, "submitted").to_string();
|
||
server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/runs");
|
||
then.respond_with(move |request| {
|
||
requests.lock().unwrap().push(
|
||
serde_json::from_slice(request.body_ref())
|
||
.expect("run-intent request body should be valid JSON"),
|
||
);
|
||
HttpMockResponse::builder()
|
||
.status(201)
|
||
.header("content-type", "application/json")
|
||
.body(response.clone())
|
||
.build()
|
||
});
|
||
})
|
||
}
|
||
|
||
fn write_workflow(root: &std::path::Path, directory: &str, graph_name: &str) -> std::path::PathBuf {
|
||
let directory = root.join(directory);
|
||
std::fs::create_dir_all(&directory).expect("workflow fixture directory should be created");
|
||
std::fs::write(
|
||
directory.join("workflow.toml"),
|
||
"_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n",
|
||
)
|
||
.expect("workflow fixture manifest should be written");
|
||
std::fs::write(
|
||
directory.join("workflow.fabro"),
|
||
format!(
|
||
"digraph {graph_name} {{ start [shape=Mdiamond] exit [shape=Msquare] start -> exit }}"
|
||
),
|
||
)
|
||
.expect("workflow fixture graph should be written");
|
||
directory.join("workflow.toml")
|
||
}
|
||
|
||
#[test]
|
||
fn help() {
|
||
let context = test_context!();
|
||
let mut cmd = context.command();
|
||
cmd.args(["create", "--help"]);
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: true
|
||
exit_code: 0
|
||
----- stdout -----
|
||
Register a local workflow version and create a submitted run
|
||
|
||
Usage: fabro create [OPTIONS] <WORKFLOW>
|
||
|
||
Arguments:
|
||
<WORKFLOW> Local workflow name, checkout path, .fabro file, or workflow TOML
|
||
|
||
Options:
|
||
--json Output as JSON [env: FABRO_JSON=]
|
||
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
||
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
||
-I, --input <KEY=VALUE> Override a workflow input value (repeatable, format: KEY=VALUE)
|
||
--dry-run Execute with simulated LLM backend
|
||
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
||
--auto-approve Auto-approve all human gates
|
||
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
||
--goal <GOAL> Override the workflow goal (available as {{ goal }} in prompts)
|
||
--goal-file <GOAL_FILE> Read a per-run goal value from a local file
|
||
--model <MODEL> Override default LLM model
|
||
--provider <PROVIDER> Override default LLM provider
|
||
-v, --verbose Enable verbose output
|
||
--environment <ENVIRONMENT> Named environment for agent tools
|
||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||
--parent <RUN> Link this run to an existing orchestration parent run
|
||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||
-d, --detach Run the workflow in the background and print the run ID
|
||
-h, --help Print help
|
||
----- stderr -----
|
||
");
|
||
}
|
||
|
||
#[test]
|
||
fn create_uses_explicit_server_target_and_prints_remote_run_id() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let environment_mock = mock_environment(&server, "default", "docker");
|
||
let version_mock = mock_workflow_version_registrations(&server);
|
||
let mock = server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/runs");
|
||
then.status(201)
|
||
.header("Content-Type", "application/json")
|
||
.body(run_status_response(run_id.as_str(), "submitted").to_string());
|
||
});
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--dry-run",
|
||
fixture("simple.fabro").to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.expect("command should execute");
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"command failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
environment_mock.assert();
|
||
version_mock.assert();
|
||
mock.assert();
|
||
assert_eq!(output_stdout(&output).trim(), run_id.as_str());
|
||
}
|
||
|
||
#[test]
|
||
fn create_reports_available_environments_when_default_is_missing() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let default_mock = server.mock(|when, then| {
|
||
when.method("GET").path("/api/v1/environments/default");
|
||
then.status(404)
|
||
.header("content-type", "application/json")
|
||
.json_body(json!({
|
||
"errors": [{
|
||
"status": "404",
|
||
"title": "Not Found",
|
||
"detail": "environment `default` not found",
|
||
"code": "environment_not_found"
|
||
}]
|
||
}));
|
||
});
|
||
let list_mock = server.mock(|when, then| {
|
||
when.method("GET").path("/api/v1/environments");
|
||
then.status(200)
|
||
.header("content-type", "application/json")
|
||
.json_body(json!({
|
||
"data": [
|
||
environment_json("production", "docker"),
|
||
environment_json("staging", "daytona"),
|
||
],
|
||
"meta": { "total": 2 }
|
||
}));
|
||
});
|
||
let create_mock = server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/runs");
|
||
then.status(500);
|
||
});
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--dry-run",
|
||
fixture("simple.fabro").to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.expect("command should execute");
|
||
|
||
assert!(!output.status.success(), "command should fail");
|
||
default_mock.assert();
|
||
list_mock.assert();
|
||
create_mock.assert_calls(0);
|
||
let stderr = output_stderr(&output);
|
||
assert!(
|
||
stderr.contains(
|
||
"environment `default` not found on the server; pass `--environment <id>` or create an environment named `default`. Available environments: `production`, `staging`."
|
||
),
|
||
"unexpected stderr:\n{stderr}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_defers_provider_validation_to_the_server() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let environment_mock = mock_environment(&server, "default", "docker");
|
||
let registered_versions = Arc::new(Mutex::new(Vec::new()));
|
||
let version_mock =
|
||
mock_workflow_version_registrations_recording(&server, Arc::clone(®istered_versions));
|
||
let mock = server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/runs");
|
||
then.status(201)
|
||
.header("Content-Type", "application/json")
|
||
.body(run_status_response(run_id.as_str(), "submitted").to_string());
|
||
});
|
||
let output = context
|
||
.create_cmd()
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--dry-run",
|
||
fixture("server-model.fabro").to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.expect("command should execute");
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"local validation should not reject a server-owned provider\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
environment_mock.assert();
|
||
version_mock.assert();
|
||
mock.assert();
|
||
assert!(
|
||
serde_json::to_string(®istered_versions.lock().unwrap()[0])
|
||
.unwrap()
|
||
.contains("server-only")
|
||
);
|
||
assert_eq!(output_stdout(&output).trim(), run_id.as_str());
|
||
}
|
||
|
||
#[test]
|
||
fn create_uses_configured_server_target_without_server_flag() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let environment_mock = mock_environment(&server, "default", "docker");
|
||
let version_mock = mock_workflow_version_registrations(&server);
|
||
let mock = server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/runs");
|
||
then.status(201)
|
||
.header("Content-Type", "application/json")
|
||
.body(run_status_response(run_id.as_str(), "submitted").to_string());
|
||
});
|
||
context.set_http_target(&server.base_url());
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.args(["--dry-run", fixture("simple.fabro").to_str().unwrap()])
|
||
.output()
|
||
.expect("command should execute");
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"command failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
environment_mock.assert();
|
||
version_mock.assert();
|
||
mock.assert();
|
||
assert_eq!(output_stdout(&output).trim(), run_id.as_str());
|
||
}
|
||
|
||
#[test]
|
||
fn create_parent_resolves_parent_and_sends_parent_id_in_intent() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let parent_id = unique_run_id();
|
||
let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id);
|
||
let environment_mock = mock_environment(&server, "default", "docker");
|
||
let version_mock = mock_workflow_version_registrations(&server);
|
||
let create_mock = server.mock(|when, then| {
|
||
when.method("POST")
|
||
.path("/api/v1/runs")
|
||
.json_body_includes(format!(r#"{{"parent_id":"{parent_id}"}}"#));
|
||
then.status(201)
|
||
.header("Content-Type", "application/json")
|
||
.body(run_status_response(run_id.as_str(), "submitted").to_string());
|
||
});
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--dry-run",
|
||
"--parent",
|
||
"nightly-parent",
|
||
fixture("simple.fabro").to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.expect("command should execute");
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"command failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
resolve_mock.assert();
|
||
environment_mock.assert();
|
||
version_mock.assert();
|
||
create_mock.assert();
|
||
assert_eq!(output_stdout(&output).trim(), run_id.as_str());
|
||
}
|
||
|
||
#[test]
|
||
fn create_rejects_storage_dir_flag() {
|
||
let context = test_context!();
|
||
let output = context
|
||
.create_cmd()
|
||
.args([
|
||
"--storage-dir",
|
||
"/tmp/fabro-create",
|
||
"--dry-run",
|
||
fixture("simple.fabro").to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.expect("command should execute");
|
||
|
||
assert!(
|
||
!output.status.success(),
|
||
"command should reject --storage-dir"
|
||
);
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(stderr.contains("unexpected argument '--storage-dir'"));
|
||
}
|
||
|
||
#[test]
|
||
fn create_cli_server_target_overrides_configured_server_target() {
|
||
let context = test_context!();
|
||
let config_server = MockServer::start();
|
||
let config_mock = config_server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/runs");
|
||
then.status(500)
|
||
.body("configured-server-should-not-be-used");
|
||
});
|
||
let cli_server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let environment_mock = mock_environment(&cli_server, "default", "docker");
|
||
let version_mock = mock_workflow_version_registrations(&cli_server);
|
||
let cli_mock = cli_server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/runs");
|
||
then.status(201)
|
||
.header("Content-Type", "application/json")
|
||
.body(run_status_response(run_id.as_str(), "submitted").to_string());
|
||
});
|
||
context.set_http_target(&config_server.base_url());
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", cli_server.base_url()),
|
||
"--dry-run",
|
||
fixture("simple.fabro").to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.expect("command should execute");
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"command failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
environment_mock.assert();
|
||
version_mock.assert();
|
||
cli_mock.assert();
|
||
config_mock.assert_calls(0);
|
||
assert_eq!(output_stdout(&output).trim(), run_id.as_str());
|
||
}
|
||
|
||
#[test]
|
||
fn create_sends_one_sparse_typed_intent_with_local_caller_target() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let parent_id = unique_run_id();
|
||
let environment_mock = mock_environment(&server, "local", "local");
|
||
let version_mock = mock_workflow_version_registrations(&server);
|
||
let resolve_mock = super::support::mock_resolved_run(&server, "parent", &parent_id);
|
||
let requests = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
|
||
let create_mock = mock_intent_create(&server, &run_id, Arc::clone(&requests));
|
||
let caller = tempfile::tempdir().unwrap();
|
||
let checkout = tempfile::tempdir().unwrap();
|
||
let workflow_dir = checkout.path().join(".fabro/workflows/exact");
|
||
std::fs::create_dir_all(&workflow_dir).unwrap();
|
||
std::fs::write(
|
||
checkout.path().join(".fabro/project.toml"),
|
||
r#"_version = 1
|
||
|
||
[run.model]
|
||
name = "project-value-must-not-cross-admission"
|
||
|
||
[environments.project-only]
|
||
provider = "local"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
workflow_dir.join("workflow.toml"),
|
||
r#"_version = 1
|
||
|
||
[workflow]
|
||
graph = "workflow.fabro"
|
||
|
||
[run.pull_request]
|
||
enabled = true
|
||
draft = false
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
workflow_dir.join("workflow.fabro"),
|
||
r#"digraph Exact {
|
||
start [shape=Mdiamond]
|
||
exit [shape=Msquare]
|
||
task [shape=parallelogram, script="echo {{ inputs.string }}"]
|
||
start -> task -> exit
|
||
}"#,
|
||
)
|
||
.unwrap();
|
||
std::fs::write(caller.path().join("goal.md"), "Goal read from caller cwd").unwrap();
|
||
context.write_home(
|
||
".fabro/settings.toml",
|
||
r#"_version = 1
|
||
|
||
[run.model]
|
||
name = "machine-value-must-not-cross-admission"
|
||
|
||
[environments.machine-only]
|
||
provider = "local"
|
||
"#,
|
||
);
|
||
let workflow = workflow_dir.join("workflow.toml");
|
||
let expected_package =
|
||
fabro_manifest::resolve_local_workflow_package(&workflow, caller.path(), None).unwrap();
|
||
let expected_id = expected_package.closure().root_id();
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.current_dir(caller.path())
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--environment",
|
||
"local",
|
||
"--parent",
|
||
"parent",
|
||
"--goal-file",
|
||
"goal.md",
|
||
"--model",
|
||
"gpt-5",
|
||
"--provider",
|
||
"openai",
|
||
"--label",
|
||
"team=cli",
|
||
"--input",
|
||
"string=hello",
|
||
"--input",
|
||
"boolean=true",
|
||
"--input",
|
||
"integer=42",
|
||
"--input",
|
||
"float=1.25",
|
||
"--dry-run",
|
||
"--auto-approve",
|
||
"--preserve-sandbox",
|
||
"--verbose",
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.expect("command should execute");
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"command failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
environment_mock.assert();
|
||
version_mock.assert();
|
||
resolve_mock.assert();
|
||
create_mock.assert_calls(1);
|
||
let requests = requests.lock().unwrap();
|
||
let [intent] = requests.as_slice() else {
|
||
panic!("expected exactly one intent request: {requests:?}");
|
||
};
|
||
assert_eq!(intent["workflow_version_id"], expected_id.to_string());
|
||
assert_eq!(
|
||
intent["target"],
|
||
json!({
|
||
"kind": "folder",
|
||
"path": caller.path().canonicalize().unwrap(),
|
||
})
|
||
);
|
||
assert_eq!(intent["environment_id"], "local");
|
||
assert_eq!(intent["parent_id"], parent_id.clone());
|
||
assert_eq!(intent["goal"], "Goal read from caller cwd");
|
||
assert_eq!(intent["args"]["model"], "gpt-5");
|
||
assert_eq!(intent["args"]["provider"], "openai");
|
||
assert_eq!(
|
||
intent["args"]["inputs"],
|
||
json!({
|
||
"string": "hello",
|
||
"boolean": true,
|
||
"integer": 42,
|
||
"float": 1.25,
|
||
})
|
||
);
|
||
assert_eq!(intent["args"]["labels"]["team"], "cli");
|
||
assert_eq!(
|
||
intent["args"]["labels"]["fabro_test_run"],
|
||
context.test_run_id()
|
||
);
|
||
assert_eq!(
|
||
intent["args"]["labels"]["fabro_test_case"],
|
||
context.test_case_id()
|
||
);
|
||
assert_eq!(intent["args"]["dry_run"], true);
|
||
assert_eq!(intent["args"]["auto_approve"], true);
|
||
assert_eq!(intent["args"]["preserve_sandbox"], true);
|
||
let wire = serde_json::to_string(intent).unwrap();
|
||
for absent in [
|
||
"goal.md",
|
||
"machine-value-must-not-cross-admission",
|
||
"project-value-must-not-cross-admission",
|
||
"workflow.toml",
|
||
"workflow.fabro",
|
||
"verbose",
|
||
] {
|
||
assert!(!wire.contains(absent), "intent leaked {absent}: {wire}");
|
||
}
|
||
let stderr = output_stderr(&output);
|
||
assert_eq!(
|
||
stderr
|
||
.lines()
|
||
.filter(|line| line.contains("do not transmit these settings"))
|
||
.count(),
|
||
2,
|
||
"{stderr}"
|
||
);
|
||
assert!(stderr.contains("contains run, environments"), "{stderr}");
|
||
assert!(!stderr.contains("machine-value-must-not-cross-admission"));
|
||
assert!(!stderr.contains("project-value-must-not-cross-admission"));
|
||
}
|
||
|
||
#[test]
|
||
fn create_preserves_named_user_other_checkout_and_loose_file_selection() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let environment_mock = mock_environment(&server, "local", "local");
|
||
let version_mock = mock_workflow_version_registrations(&server);
|
||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||
let create_mock = mock_intent_create(&server, &run_id, Arc::clone(&requests));
|
||
let project = tempfile::tempdir().unwrap();
|
||
let outside = tempfile::tempdir().unwrap();
|
||
let other_checkout = tempfile::tempdir().unwrap();
|
||
run_git(project.path(), &["init", "--quiet"]);
|
||
let project_workflow = write_workflow(project.path(), ".fabro/workflows/hello", "ProjectHello");
|
||
let user_root = context.home_dir.join(".fabro/workflows");
|
||
let user_workflow = write_workflow(&user_root, "hello", "UserHello");
|
||
let other_workflow = write_workflow(
|
||
other_checkout.path(),
|
||
".fabro/workflows/other",
|
||
"OtherCheckout",
|
||
);
|
||
let loose_workflow = outside.path().join("loose.fabro");
|
||
std::fs::write(
|
||
&loose_workflow,
|
||
"digraph Loose { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
|
||
)
|
||
.unwrap();
|
||
|
||
let project_expected = fabro_manifest::resolve_local_workflow_package(
|
||
std::path::Path::new("hello"),
|
||
project.path(),
|
||
Some(&user_root),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
project_expected.workflow_location().toml.as_ref(),
|
||
Some(&project_workflow.canonicalize().unwrap())
|
||
);
|
||
let user_expected = fabro_manifest::resolve_local_workflow_package(
|
||
std::path::Path::new("hello"),
|
||
outside.path(),
|
||
Some(&user_root),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
user_expected.workflow_location().toml.as_ref(),
|
||
Some(&user_workflow.canonicalize().unwrap())
|
||
);
|
||
let other_expected = fabro_manifest::resolve_local_workflow_package(
|
||
&other_workflow,
|
||
outside.path(),
|
||
Some(&user_root),
|
||
)
|
||
.unwrap();
|
||
let loose_expected = fabro_manifest::resolve_local_workflow_package(
|
||
&loose_workflow,
|
||
outside.path(),
|
||
Some(&user_root),
|
||
)
|
||
.unwrap();
|
||
let expected = [
|
||
project_expected.closure().root_id(),
|
||
user_expected.closure().root_id(),
|
||
other_expected.closure().root_id(),
|
||
loose_expected.closure().root_id(),
|
||
];
|
||
|
||
for (cwd, workflow) in [
|
||
(project.path(), std::path::Path::new("hello")),
|
||
(outside.path(), std::path::Path::new("hello")),
|
||
(outside.path(), other_workflow.as_path()),
|
||
(outside.path(), loose_workflow.as_path()),
|
||
] {
|
||
let output = context
|
||
.create_cmd()
|
||
.current_dir(cwd)
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--environment",
|
||
"local",
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
output.status.success(),
|
||
"create failed for {}:\n{}",
|
||
workflow.display(),
|
||
output_stderr(&output)
|
||
);
|
||
}
|
||
|
||
environment_mock.assert_calls(4);
|
||
version_mock.assert_calls(4);
|
||
create_mock.assert_calls(4);
|
||
let requests = requests.lock().unwrap();
|
||
assert_eq!(requests.len(), expected.len());
|
||
for (request, expected_id) in requests.iter().zip(expected) {
|
||
assert_eq!(request["workflow_version_id"], expected_id.to_string());
|
||
}
|
||
assert_eq!(
|
||
requests[0]["target"]["path"],
|
||
project.path().canonicalize().unwrap().to_str().unwrap()
|
||
);
|
||
for request in &requests[1..] {
|
||
assert_eq!(
|
||
request["target"]["path"],
|
||
outside.path().canonicalize().unwrap().to_str().unwrap()
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn create_clone_targets_require_exact_git_observations() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let environment_calls = Arc::new(AtomicUsize::new(0));
|
||
let environment_calls_for_mock = Arc::clone(&environment_calls);
|
||
let environment_mock = server.mock(|when, then| {
|
||
when.method("GET").path("/api/v1/environments/default");
|
||
then.respond_with(move |_| {
|
||
let provider = match environment_calls_for_mock.fetch_add(1, Ordering::SeqCst) {
|
||
0 | 2 => "docker",
|
||
1 => "daytona",
|
||
call => panic!("unexpected environment retrieval {call}"),
|
||
};
|
||
HttpMockResponse::builder()
|
||
.status(200)
|
||
.header("content-type", "application/json")
|
||
.body(environment_json("default", provider).to_string())
|
||
.build()
|
||
});
|
||
});
|
||
let version_mock = mock_workflow_version_registrations(&server);
|
||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||
let create_mock = mock_intent_create(&server, &run_id, Arc::clone(&requests));
|
||
let fixture_root = tempfile::tempdir().unwrap();
|
||
let workflow = write_workflow(fixture_root.path(), "workflow", "CloneTarget");
|
||
|
||
let exact = tempfile::tempdir().unwrap();
|
||
let bare = fixture_root.path().join("origin.git");
|
||
run_git(fixture_root.path(), &[
|
||
"init",
|
||
"--bare",
|
||
"--quiet",
|
||
bare.to_str().unwrap(),
|
||
]);
|
||
run_git(exact.path(), &[
|
||
"-c",
|
||
"init.defaultBranch=feature",
|
||
"init",
|
||
"--quiet",
|
||
]);
|
||
std::fs::write(exact.path().join("tracked.txt"), "tracked").unwrap();
|
||
run_git(exact.path(), &["add", "tracked.txt"]);
|
||
run_git(exact.path(), &[
|
||
"-c",
|
||
"user.name=test",
|
||
"-c",
|
||
"user.email=test@example.com",
|
||
"commit",
|
||
"--quiet",
|
||
"-m",
|
||
"initial",
|
||
]);
|
||
run_git(exact.path(), &[
|
||
"remote",
|
||
"add",
|
||
"origin",
|
||
"https://github.com/acme/widgets.git",
|
||
]);
|
||
let local_url = format!("file://{}", bare.display());
|
||
run_git(exact.path(), &[
|
||
"remote", "set-url", "--push", "origin", &local_url,
|
||
]);
|
||
std::fs::write(exact.path().join("dirty.txt"), "not committed").unwrap();
|
||
let exact_output = context
|
||
.create_cmd()
|
||
.current_dir(exact.path())
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--dry-run",
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
exact_output.status.success(),
|
||
"{}",
|
||
output_stderr(&exact_output)
|
||
);
|
||
assert_eq!(
|
||
output_stderr(&exact_output)
|
||
.lines()
|
||
.filter(|line| line.contains("working tree is dirty"))
|
||
.count(),
|
||
1
|
||
);
|
||
|
||
let branch_only = tempfile::tempdir().unwrap();
|
||
run_git(branch_only.path(), &[
|
||
"-c",
|
||
"init.defaultBranch=topic",
|
||
"init",
|
||
"--quiet",
|
||
]);
|
||
std::fs::write(branch_only.path().join("tracked.txt"), "tracked").unwrap();
|
||
run_git(branch_only.path(), &["add", "tracked.txt"]);
|
||
run_git(branch_only.path(), &[
|
||
"-c",
|
||
"user.name=test",
|
||
"-c",
|
||
"user.email=test@example.com",
|
||
"commit",
|
||
"--quiet",
|
||
"-m",
|
||
"initial",
|
||
]);
|
||
run_git(branch_only.path(), &[
|
||
"remote",
|
||
"add",
|
||
"origin",
|
||
"https://github.com/acme/missing.git",
|
||
]);
|
||
let missing_url = format!("file://{}/missing.git", fixture_root.path().display());
|
||
run_git(branch_only.path(), &[
|
||
"remote",
|
||
"set-url",
|
||
"--push",
|
||
"origin",
|
||
&missing_url,
|
||
]);
|
||
let branch_output = context
|
||
.create_cmd()
|
||
.current_dir(branch_only.path())
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
assert!(!branch_output.status.success());
|
||
let branch_stderr = output_stderr(&branch_output);
|
||
assert!(
|
||
branch_stderr.contains(
|
||
"the exact local Git commit could not be made available from the canonical GitHub origin"
|
||
),
|
||
"{branch_stderr}"
|
||
);
|
||
assert!(!branch_stderr.contains("file://"));
|
||
assert!(!branch_stderr.contains("No such file or directory"));
|
||
|
||
let no_repository = tempfile::tempdir().unwrap();
|
||
let none_output = context
|
||
.create_cmd()
|
||
.current_dir(no_repository.path())
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
none_output.status.success(),
|
||
"{}",
|
||
output_stderr(&none_output)
|
||
);
|
||
|
||
environment_mock.assert_calls(3);
|
||
version_mock.assert_calls(2);
|
||
create_mock.assert_calls(2);
|
||
let requests = requests.lock().unwrap();
|
||
assert_eq!(requests[0]["args"]["dry_run"], true);
|
||
assert_eq!(
|
||
requests[0]["target"],
|
||
json!({
|
||
"kind": "git",
|
||
"repo": "acme/widgets",
|
||
"branch": "feature",
|
||
"sha": run_git(exact.path(), &["rev-parse", "HEAD"]),
|
||
})
|
||
);
|
||
assert_eq!(requests[1]["target"], json!({ "kind": "none" }));
|
||
}
|
||
|
||
#[test]
|
||
fn create_rejects_unusable_git_checkouts_instead_of_sending_an_empty_target() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let environment_mock = mock_environment(&server, "default", "docker");
|
||
let version_mock = mock_workflow_version_registrations(&server);
|
||
let run_id = unique_run_id();
|
||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||
let create_mock = mock_intent_create(&server, &run_id, requests);
|
||
let fixture_root = tempfile::tempdir().unwrap();
|
||
let workflow = write_workflow(fixture_root.path(), "workflow", "UnusableCheckout");
|
||
|
||
let detached = tempfile::tempdir().unwrap();
|
||
run_git(detached.path(), &[
|
||
"-c",
|
||
"init.defaultBranch=feature",
|
||
"init",
|
||
"--quiet",
|
||
]);
|
||
std::fs::write(detached.path().join("tracked.txt"), "tracked").unwrap();
|
||
run_git(detached.path(), &["add", "tracked.txt"]);
|
||
run_git(detached.path(), &[
|
||
"-c",
|
||
"user.name=test",
|
||
"-c",
|
||
"user.email=test@example.com",
|
||
"commit",
|
||
"--quiet",
|
||
"-m",
|
||
"initial",
|
||
]);
|
||
run_git(detached.path(), &["checkout", "--detach", "--quiet"]);
|
||
|
||
let unborn = tempfile::tempdir().unwrap();
|
||
run_git(unborn.path(), &[
|
||
"-c",
|
||
"init.defaultBranch=feature",
|
||
"init",
|
||
"--quiet",
|
||
]);
|
||
|
||
for (working_directory, expected_error) in [
|
||
(
|
||
detached.path(),
|
||
"the caller Git checkout has a detached HEAD",
|
||
),
|
||
(unborn.path(), "the caller Git checkout has no commits"),
|
||
] {
|
||
let output = context
|
||
.create_cmd()
|
||
.current_dir(working_directory)
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
|
||
assert!(!output.status.success());
|
||
let stderr = output_stderr(&output);
|
||
assert!(stderr.contains(expected_error), "{stderr}");
|
||
}
|
||
|
||
environment_mock.assert_calls(2);
|
||
version_mock.assert_calls(0);
|
||
create_mock.assert_calls(0);
|
||
}
|
||
|
||
#[test]
|
||
fn create_rejects_an_unsupported_attached_origin_before_upload() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let environment_mock = mock_environment(&server, "default", "docker");
|
||
let version_mock = mock_workflow_version_registrations(&server);
|
||
let run_id = unique_run_id();
|
||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||
let create_mock = mock_intent_create(&server, &run_id, requests);
|
||
let fixture_root = tempfile::tempdir().unwrap();
|
||
let workflow = write_workflow(fixture_root.path(), "workflow", "UnsupportedOrigin");
|
||
let workspace = tempfile::tempdir().unwrap();
|
||
let bare = fixture_root.path().join("unsupported.git");
|
||
run_git(fixture_root.path(), &[
|
||
"init",
|
||
"--bare",
|
||
"--quiet",
|
||
bare.to_str().unwrap(),
|
||
]);
|
||
run_git(workspace.path(), &[
|
||
"-c",
|
||
"init.defaultBranch=feature",
|
||
"init",
|
||
"--quiet",
|
||
]);
|
||
std::fs::write(workspace.path().join("tracked.txt"), "tracked").unwrap();
|
||
run_git(workspace.path(), &["add", "tracked.txt"]);
|
||
run_git(workspace.path(), &[
|
||
"-c",
|
||
"user.name=test",
|
||
"-c",
|
||
"user.email=test@example.com",
|
||
"commit",
|
||
"--quiet",
|
||
"-m",
|
||
"initial",
|
||
]);
|
||
run_git(workspace.path(), &[
|
||
"remote",
|
||
"add",
|
||
"origin",
|
||
bare.to_str().unwrap(),
|
||
]);
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.current_dir(workspace.path())
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
|
||
assert!(!output.status.success());
|
||
assert!(
|
||
output_stderr(&output).contains("cannot be represented as a canonical GitHub run target")
|
||
);
|
||
environment_mock.assert();
|
||
version_mock.assert_calls(0);
|
||
create_mock.assert_calls(0);
|
||
}
|
||
|
||
#[test]
|
||
fn create_registers_dependencies_before_the_root_and_then_creates_once() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let environment_mock = mock_environment(&server, "local", "local");
|
||
let registrations = Arc::new(Mutex::new(Vec::new()));
|
||
let version_mock =
|
||
mock_workflow_version_registrations_recording(&server, Arc::clone(®istrations));
|
||
let requests = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
|
||
let requests_for_mock = Arc::clone(&requests);
|
||
let registrations_for_create = Arc::clone(®istrations);
|
||
let create_response = run_status_response(run_id.as_str(), "submitted").to_string();
|
||
let create_mock = server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/runs");
|
||
then.respond_with(move |request| {
|
||
assert_eq!(
|
||
registrations_for_create.lock().unwrap().len(),
|
||
2,
|
||
"intent create arrived before dependency and root registration"
|
||
);
|
||
requests_for_mock
|
||
.lock()
|
||
.unwrap()
|
||
.push(serde_json::from_slice(request.body_ref()).unwrap());
|
||
HttpMockResponse::builder()
|
||
.status(201)
|
||
.header("content-type", "application/json")
|
||
.body(create_response.clone())
|
||
.build()
|
||
});
|
||
});
|
||
let project = tempfile::tempdir().unwrap();
|
||
run_git(project.path(), &["init", "--quiet"]);
|
||
write_workflow(project.path(), ".fabro/workflows/root", "Root");
|
||
write_workflow(project.path(), ".fabro/workflows/child", "Child");
|
||
std::fs::write(
|
||
project.path().join(".fabro/workflows/root/workflow.fabro"),
|
||
r#"digraph Root {
|
||
start [shape=Mdiamond]
|
||
exit [shape=Msquare]
|
||
child [shape=house, stack.child_workflow="../child/workflow.fabro"]
|
||
start -> child -> exit
|
||
}"#,
|
||
)
|
||
.unwrap();
|
||
let expected = fabro_manifest::resolve_local_workflow_package(
|
||
std::path::Path::new("root"),
|
||
project.path(),
|
||
None,
|
||
)
|
||
.unwrap();
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.current_dir(project.path())
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--environment",
|
||
"local",
|
||
"root",
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
|
||
assert!(output.status.success(), "{}", output_stderr(&output));
|
||
environment_mock.assert();
|
||
version_mock.assert_calls(2);
|
||
create_mock.assert_calls(1);
|
||
let registered_entrypoints: Vec<String> = registrations
|
||
.lock()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|version| version["entrypoint"].as_str().unwrap().to_string())
|
||
.collect();
|
||
assert_eq!(registered_entrypoints, [
|
||
".fabro/workflows/child/workflow.fabro",
|
||
".fabro/workflows/root/workflow.fabro",
|
||
]);
|
||
assert_eq!(
|
||
requests.lock().unwrap()[0]["workflow_version_id"],
|
||
expected.closure().root_id().to_string()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_stops_before_intent_create_when_registration_fails() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let environment_mock = mock_environment(&server, "local", "local");
|
||
let version_mock = server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/workflow-versions");
|
||
then.status(409)
|
||
.header("content-type", "text/plain")
|
||
.body("workflow version rejected by fixture");
|
||
});
|
||
let run_id = unique_run_id();
|
||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||
let create_mock = mock_intent_create(&server, &run_id, requests);
|
||
let source = tempfile::tempdir().unwrap();
|
||
let caller = tempfile::tempdir().unwrap();
|
||
let workflow = write_workflow(source.path(), "workflow", "RegistrationFailure");
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.current_dir(caller.path())
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--environment",
|
||
"local",
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
|
||
assert!(!output.status.success());
|
||
environment_mock.assert();
|
||
version_mock.assert();
|
||
create_mock.assert_calls(0);
|
||
let stderr = output_stderr(&output);
|
||
assert!(
|
||
stderr.contains("could not register workflow versions"),
|
||
"{stderr}"
|
||
);
|
||
assert!(stderr.contains("index 0"), "{stderr}");
|
||
assert!(
|
||
stderr.contains("workflow version rejected by fixture"),
|
||
"{stderr}"
|
||
);
|
||
assert!(stderr.contains("409 Conflict"), "{stderr}");
|
||
}
|
||
|
||
#[test]
|
||
fn create_persists_directory_workflow_slug_and_cached_graph() {
|
||
let context = test_context!();
|
||
context.ensure_home_server_auth_methods();
|
||
let workflow_path = context.temp_dir.join("sluggy/workflow.fabro");
|
||
|
||
context.write_temp(
|
||
"sluggy/workflow.fabro",
|
||
"\
|
||
digraph BarBaz {
|
||
start [shape=Mdiamond, label=\"Start\"]
|
||
exit [shape=Msquare, label=\"Exit\"]
|
||
start -> exit
|
||
}
|
||
",
|
||
);
|
||
|
||
let create = context
|
||
.command()
|
||
.args([
|
||
"create",
|
||
"--dry-run",
|
||
"--auto-approve",
|
||
"--environment",
|
||
"local",
|
||
workflow_path.to_str().unwrap(),
|
||
])
|
||
.assert()
|
||
.success();
|
||
let run_id = created_run_id(create.get_output());
|
||
|
||
let run_dir = context.find_run_dir(&run_id);
|
||
let state = run_state(&run_dir);
|
||
let run = &state.spec;
|
||
fabro_json_snapshot!(
|
||
context,
|
||
serde_json::json!({
|
||
"workflow_slug": run.workflow_slug,
|
||
"graph_name": run.graph.name,
|
||
"cached_graph_lines": state.spec.graph_source.as_ref().expect("graph should exist").lines().collect::<Vec<_>>(),
|
||
}),
|
||
@r#"
|
||
{
|
||
"workflow_slug": "workflow",
|
||
"graph_name": "BarBaz",
|
||
"cached_graph_lines": [
|
||
"digraph BarBaz {",
|
||
" start [shape=Mdiamond, label=\"Start\"]",
|
||
" exit [shape=Msquare, label=\"Exit\"]",
|
||
" start -> exit",
|
||
"}"
|
||
]
|
||
}
|
||
"#
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_persists_file_stem_slug_for_standalone_file() {
|
||
let context = test_context!();
|
||
context.ensure_home_server_auth_methods();
|
||
let workflow_path = context.temp_dir.join("alpha.fabro");
|
||
|
||
context.write_temp(
|
||
"alpha.fabro",
|
||
"\
|
||
digraph FooWorkflow {
|
||
start [shape=Mdiamond, label=\"Start\"]
|
||
exit [shape=Msquare, label=\"Exit\"]
|
||
start -> exit
|
||
}
|
||
",
|
||
);
|
||
|
||
let create = context
|
||
.command()
|
||
.args([
|
||
"create",
|
||
"--dry-run",
|
||
"--auto-approve",
|
||
"--environment",
|
||
"local",
|
||
workflow_path.to_str().unwrap(),
|
||
])
|
||
.assert()
|
||
.success();
|
||
let run_id = created_run_id(create.get_output());
|
||
|
||
let run_dir = context.find_run_dir(&run_id);
|
||
let state = run_state(&run_dir);
|
||
let run = &state.spec;
|
||
fabro_json_snapshot!(
|
||
context,
|
||
serde_json::json!({
|
||
"workflow_slug": run.workflow_slug,
|
||
"graph_name": run.graph.name,
|
||
"cached_graph_lines": state.spec.graph_source.as_ref().expect("graph should exist").lines().collect::<Vec<_>>(),
|
||
}),
|
||
@r#"
|
||
{
|
||
"workflow_slug": "alpha",
|
||
"graph_name": "FooWorkflow",
|
||
"cached_graph_lines": [
|
||
"digraph FooWorkflow {",
|
||
" start [shape=Mdiamond, label=\"Start\"]",
|
||
" exit [shape=Msquare, label=\"Exit\"]",
|
||
" start -> exit",
|
||
"}"
|
||
]
|
||
}
|
||
"#
|
||
);
|
||
}
|
||
|
||
#[expect(
|
||
clippy::disallowed_methods,
|
||
reason = "test asserts the raw template source"
|
||
)]
|
||
#[test]
|
||
fn create_persists_requested_overrides_into_store() {
|
||
let context = test_context!();
|
||
context.ensure_home_server_auth_methods();
|
||
let workflow = fixture("simple.fabro");
|
||
let mut cmd = context.command();
|
||
cmd.args([
|
||
"create",
|
||
"--dry-run",
|
||
"--auto-approve",
|
||
"--goal",
|
||
"Ship the release",
|
||
"--model",
|
||
"gpt-5",
|
||
"--provider",
|
||
"openai",
|
||
"--environment",
|
||
"local",
|
||
"--label",
|
||
"env=dev",
|
||
"--label",
|
||
"team=cli",
|
||
"--verbose",
|
||
"--preserve-sandbox",
|
||
workflow.to_str().unwrap(),
|
||
]);
|
||
let output = cmd.output().expect("command should execute");
|
||
assert!(
|
||
output.status.success(),
|
||
"command failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
|
||
let stdout = output_stdout(&output);
|
||
let run_id = stdout
|
||
.lines()
|
||
.find(|line| !line.trim().is_empty())
|
||
.map(str::trim)
|
||
.expect("create should print a run ID")
|
||
.to_string();
|
||
let run = resolve_run(&context, &run_id);
|
||
let state = run_state(&run.run_dir);
|
||
let run_spec = &state.spec;
|
||
let labels = json!({
|
||
"env": run_spec.labels.get("env"),
|
||
"team": run_spec.labels.get("team"),
|
||
});
|
||
let settings = &run_spec.settings;
|
||
let resolved_run = resolved_run(settings);
|
||
let compact = json!({
|
||
"workflow_slug": run_spec.workflow_slug,
|
||
"settings": {
|
||
"goal": match resolved_run.goal.as_ref() {
|
||
Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()),
|
||
_ => None,
|
||
},
|
||
"dry_run": resolved_run.execution.mode == fabro_types::settings::run::RunMode::DryRun,
|
||
"auto_approve": resolved_run.execution.approval == fabro_types::settings::run::ApprovalMode::Auto,
|
||
"llm": {
|
||
"model": resolved_run.model.name.clone(),
|
||
"provider": resolved_run.model.provider.clone(),
|
||
},
|
||
"environment": {
|
||
"id": resolved_run.environment.id,
|
||
"provider": resolved_run.environment.provider.to_string(),
|
||
"preserve": resolved_run.environment.lifecycle.preserve,
|
||
},
|
||
},
|
||
"labels": labels,
|
||
});
|
||
|
||
assert_snapshot!(serde_json::to_string_pretty(&compact).unwrap(), @r###"
|
||
{
|
||
"workflow_slug": "simple",
|
||
"settings": {
|
||
"goal": "Ship the release",
|
||
"dry_run": true,
|
||
"auto_approve": true,
|
||
"llm": {
|
||
"model": "gpt-5",
|
||
"provider": "openai"
|
||
},
|
||
"environment": {
|
||
"id": "local",
|
||
"provider": "local",
|
||
"preserve": true
|
||
}
|
||
},
|
||
"labels": {
|
||
"env": "dev",
|
||
"team": "cli"
|
||
}
|
||
}
|
||
"###);
|
||
}
|
||
|
||
#[test]
|
||
fn create_json_does_not_imply_auto_approve() {
|
||
let context = test_context!();
|
||
context.ensure_home_server_auth_methods();
|
||
let workflow = fixture("simple.fabro");
|
||
let output = context
|
||
.command()
|
||
.args([
|
||
"--json",
|
||
"create",
|
||
"--dry-run",
|
||
"--environment",
|
||
"local",
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.expect("command should execute");
|
||
|
||
assert!(
|
||
output.status.success(),
|
||
"command failed:\nstdout:\n{}\nstderr:\n{}",
|
||
String::from_utf8_lossy(&output.stdout),
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
|
||
let value: serde_json::Value =
|
||
serde_json::from_slice(&output.stdout).expect("create JSON should parse");
|
||
let run_id = value["run_id"]
|
||
.as_str()
|
||
.expect("create JSON should include run_id");
|
||
let run = resolve_run(&context, run_id);
|
||
|
||
assert!(
|
||
resolved_run(&run_state(&run.run_dir).spec.settings,)
|
||
.execution
|
||
.approval
|
||
!= fabro_types::settings::run::ApprovalMode::Auto
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_invalid_workflow_fails_without_creating_run() {
|
||
let context = test_context!();
|
||
let caller = tempfile::tempdir().unwrap();
|
||
let workflow = fixture("invalid.fabro");
|
||
let initial_run_count = run_count_for_test_case(&context);
|
||
let mut cmd = context.create_cmd();
|
||
cmd.current_dir(caller.path())
|
||
.args(["--quiet", workflow.to_str().unwrap()]);
|
||
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: false
|
||
exit_code: 1
|
||
----- stdout -----
|
||
----- stderr -----
|
||
× could not create run
|
||
╰─▶ run intent could not be compiled: Validation failed
|
||
");
|
||
|
||
let run_count = run_count_for_test_case(&context);
|
||
assert_eq!(
|
||
run_count, initial_run_count,
|
||
"invalid create should not persist a run for this test case"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_rejects_unbound_template_inputs_without_creating_run() {
|
||
let context = test_context!();
|
||
let caller = tempfile::tempdir().unwrap();
|
||
let workflow = fixture("templated_unbound.fabro");
|
||
let initial_run_count = run_count_for_test_case(&context);
|
||
let mut cmd = context.create_cmd();
|
||
cmd.current_dir(caller.path())
|
||
.args(["--quiet", workflow.to_str().unwrap()]);
|
||
|
||
fabro_snapshot!(context.filters(), cmd, @"
|
||
success: false
|
||
exit_code: 1
|
||
----- stdout -----
|
||
----- stderr -----
|
||
× could not create run
|
||
╰─▶ run intent could not be compiled: Validation failed
|
||
");
|
||
|
||
let run_count = run_count_for_test_case(&context);
|
||
assert_eq!(
|
||
run_count, initial_run_count,
|
||
"invalid create should not persist a run for this test case"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_registers_package_before_surfacing_server_admission_rejection() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let environment_mock = mock_environment(&server, "local", "local");
|
||
let registered_versions = Arc::new(Mutex::new(Vec::new()));
|
||
let version_mock =
|
||
mock_workflow_version_registrations_recording(&server, Arc::clone(®istered_versions));
|
||
let registered_versions_for_create = Arc::clone(®istered_versions);
|
||
let create_mock = server.mock(|when, then| {
|
||
when.method("POST").path("/api/v1/runs");
|
||
then.respond_with(move |_| {
|
||
assert_eq!(
|
||
registered_versions_for_create.lock().unwrap().len(),
|
||
1,
|
||
"the workflow version must be registered before server admission"
|
||
);
|
||
HttpMockResponse::builder()
|
||
.status(422)
|
||
.header("content-type", "text/plain")
|
||
.body("server-authoritative workflow rejection")
|
||
.build()
|
||
});
|
||
});
|
||
let output = context
|
||
.create_cmd()
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--environment",
|
||
"local",
|
||
fixture("templated_unbound.fabro").to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
assert!(!output.status.success());
|
||
environment_mock.assert();
|
||
version_mock.assert();
|
||
create_mock.assert();
|
||
let stderr = output_stderr(&output);
|
||
assert!(stderr.contains("could not create run"), "{stderr}");
|
||
assert!(
|
||
stderr.contains("server-authoritative workflow rejection"),
|
||
"{stderr}"
|
||
);
|
||
assert!(!stderr.contains("Validation failed"), "{stderr}");
|
||
}
|
||
|
||
#[test]
|
||
fn create_does_not_warn_for_client_only_settings() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let run_id = unique_run_id();
|
||
let environment_mock = mock_environment(&server, "local", "local");
|
||
let version_mock = mock_workflow_version_registrations(&server);
|
||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||
let create_mock = mock_intent_create(&server, &run_id, requests);
|
||
let project = tempfile::tempdir().unwrap();
|
||
std::fs::create_dir_all(project.path().join(".fabro")).unwrap();
|
||
std::fs::write(
|
||
project.path().join(".fabro/project.toml"),
|
||
"_version = 1\n\n[cli.output]\nverbosity = \"quiet\"\n",
|
||
)
|
||
.unwrap();
|
||
let workflow = write_workflow(project.path(), "workflow", "ClientOnly");
|
||
context.write_home(
|
||
".fabro/settings.toml",
|
||
"_version = 1\n\n[cli.output]\nverbosity = \"quiet\"\n",
|
||
);
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.current_dir(project.path())
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
"--environment",
|
||
"local",
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
|
||
assert!(output.status.success(), "{}", output_stderr(&output));
|
||
environment_mock.assert();
|
||
version_mock.assert();
|
||
create_mock.assert();
|
||
assert!(!output_stderr(&output).contains("do not transmit these settings"));
|
||
}
|
||
|
||
#[test]
|
||
fn create_rejects_malformed_discovered_project_config_before_server_access() {
|
||
let context = test_context!();
|
||
let server = MockServer::start();
|
||
let any_request = server.mock(|when, then| {
|
||
when.any_request();
|
||
then.status(500).body("server must remain untouched");
|
||
});
|
||
let project = tempfile::tempdir().unwrap();
|
||
std::fs::create_dir_all(project.path().join(".fabro")).unwrap();
|
||
let project_config = project.path().join(".fabro/project.toml");
|
||
std::fs::write(&project_config, "_version = 1\nrun = [").unwrap();
|
||
let workflow = write_workflow(project.path(), "workflow", "MalformedProject");
|
||
|
||
let output = context
|
||
.create_cmd()
|
||
.current_dir(project.path())
|
||
.args([
|
||
"--server",
|
||
&format!("{}/api/v1", server.base_url()),
|
||
workflow.to_str().unwrap(),
|
||
])
|
||
.output()
|
||
.unwrap();
|
||
|
||
assert!(!output.status.success());
|
||
let stderr = output_stderr(&output);
|
||
assert!(
|
||
stderr.contains(project_config.to_str().unwrap()),
|
||
"{stderr}"
|
||
);
|
||
assert!(
|
||
stderr.contains("TOML") || stderr.contains("toml"),
|
||
"{stderr}"
|
||
);
|
||
any_request.assert_calls(0);
|
||
}
|
||
|
||
#[test]
|
||
fn create_uses_workflow_owned_pull_request_settings_only() {
|
||
let context = test_context!();
|
||
context.ensure_home_server_auth_methods();
|
||
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.pull_request]
|
||
enabled = true
|
||
draft = false
|
||
",
|
||
)
|
||
.unwrap();
|
||
let workflow = write_workflow(project.path(), "workflow", "PullRequestAuthority");
|
||
|
||
let project_only = context
|
||
.create_cmd()
|
||
.current_dir(project.path())
|
||
.args(["--environment", "local", workflow.to_str().unwrap()])
|
||
.assert()
|
||
.success();
|
||
assert!(
|
||
String::from_utf8_lossy(&project_only.get_output().stderr)
|
||
.contains("project.toml contains run")
|
||
);
|
||
let project_only_id = created_run_id(project_only.get_output());
|
||
let project_only_state = run_state(&context.find_run_dir(&project_only_id));
|
||
assert_eq!(
|
||
project_only_state.spec.settings.run.pull_request, None,
|
||
"project-only pull-request settings must not cross intent admission"
|
||
);
|
||
|
||
std::fs::write(
|
||
&workflow,
|
||
r#"_version = 1
|
||
|
||
[workflow]
|
||
graph = "workflow.fabro"
|
||
|
||
[run.pull_request]
|
||
enabled = true
|
||
draft = false
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let workflow_owned = context
|
||
.create_cmd()
|
||
.current_dir(project.path())
|
||
.args(["--environment", "default", workflow.to_str().unwrap()])
|
||
.assert()
|
||
.success();
|
||
let workflow_owned_id = created_run_id(workflow_owned.get_output());
|
||
let workflow_owned_state = run_state(&context.find_run_dir(&workflow_owned_id));
|
||
let pull_request = workflow_owned_state
|
||
.spec
|
||
.settings
|
||
.run
|
||
.pull_request
|
||
.as_ref()
|
||
.expect("workflow.toml should configure pull-request behavior");
|
||
assert!(pull_request.enabled);
|
||
assert!(!pull_request.draft);
|
||
}
|