mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-05 02:41:45 +00:00
1478 lines
43 KiB
Rust
1478 lines
43 KiB
Rust
use fabro_test::{fabro_snapshot, test_context};
|
|
use httpmock::MockServer;
|
|
use serde_json::Value;
|
|
|
|
use super::support::{output_stderr, wait_for_event_names};
|
|
use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id};
|
|
|
|
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"id": run_id,
|
|
"status": status,
|
|
"created_at": "2026-04-05T12:00:00Z"
|
|
})
|
|
}
|
|
|
|
fn preflight_response() -> serde_json::Value {
|
|
serde_json::json!({
|
|
"ok": true,
|
|
"workflow": {
|
|
"name": "Simple",
|
|
"graph_path": null,
|
|
"nodes": 4,
|
|
"edges": 3,
|
|
"goal": "Run tests and report results",
|
|
"diagnostics": []
|
|
},
|
|
"checks": {
|
|
"title": "Preflight",
|
|
"sections": []
|
|
}
|
|
})
|
|
}
|
|
|
|
fn remote_run_state_response() -> serde_json::Value {
|
|
serde_json::json!({
|
|
"run": null,
|
|
"graph_source": null,
|
|
"start": null,
|
|
"status": null,
|
|
"checkpoint": {
|
|
"timestamp": "2026-04-05T12:00:01Z",
|
|
"current_node": "exit",
|
|
"completed_nodes": ["report"],
|
|
"node_retries": {},
|
|
"context_values": {
|
|
"response.report": "Remote output"
|
|
},
|
|
"node_outcomes": {},
|
|
"next_node_id": null,
|
|
"git_commit_sha": null,
|
|
"loop_failure_signatures": {},
|
|
"restart_failure_signatures": {},
|
|
"node_visits": {}
|
|
},
|
|
"checkpoints": [],
|
|
"conclusion": {
|
|
"timestamp": "2026-04-05T12:00:01Z",
|
|
"status": "success",
|
|
"duration_ms": 12,
|
|
"stages": [],
|
|
"billing": null,
|
|
"total_retries": 0,
|
|
},
|
|
"retro": null,
|
|
"retro_prompt": null,
|
|
"retro_response": null,
|
|
"sandbox": null,
|
|
"final_patch": null,
|
|
"pull_request": null,
|
|
"nodes": {}
|
|
})
|
|
}
|
|
|
|
fn run_completed_event(run_id: &str) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"seq": 1,
|
|
"payload": {
|
|
"event": "run.completed",
|
|
"id": "evt-run-completed",
|
|
"run_id": run_id,
|
|
"ts": "2026-04-05T12:00:01Z",
|
|
"properties": {
|
|
"duration_ms": 12,
|
|
"artifact_count": 0,
|
|
"status": "success"
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn run_running_event(run_id: &str, seq: u32) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"seq": seq,
|
|
"payload": {
|
|
"event": "run.running",
|
|
"id": format!("evt-run-running-{seq}"),
|
|
"run_id": run_id,
|
|
"ts": "2026-04-05T12:00:00Z",
|
|
"properties": {}
|
|
}
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn help() {
|
|
let context = test_context!();
|
|
let mut cmd = context.run_cmd();
|
|
cmd.arg("--help");
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
Launch a workflow run
|
|
|
|
Usage: fabro run [OPTIONS] <WORKFLOW>
|
|
|
|
Arguments:
|
|
<WORKFLOW> Path to a .fabro workflow file or .toml task config
|
|
|
|
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=]
|
|
--dry-run Execute with simulated LLM backend
|
|
--auto-approve Auto-approve all human gates
|
|
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
|
--goal <GOAL> Override the workflow goal (exposed as $goal in prompts)
|
|
--quiet Suppress non-essential output [env: FABRO_QUIET=]
|
|
--goal-file <GOAL_FILE> Read the workflow goal from a file
|
|
--model <MODEL> Override default LLM model
|
|
--provider <PROVIDER> Override default LLM provider
|
|
-v, --verbose Enable verbose output
|
|
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
|
|
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
|
--no-retro Skip retro generation after the 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 detach_uses_explicit_server_target_and_prints_remote_run_id() {
|
|
let context = test_context!();
|
|
let server = MockServer::start();
|
|
let run_id = unique_run_id();
|
|
let create_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 start_mock = server.mock(|when, then| {
|
|
when.method("POST")
|
|
.path(format!("/api/v1/runs/{run_id}/start"));
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(run_status_response(run_id.as_str(), "queued").to_string());
|
|
});
|
|
|
|
let output = context
|
|
.run_cmd()
|
|
.args([
|
|
"--server",
|
|
&format!("{}/api/v1", server.base_url()),
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
example_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)
|
|
);
|
|
create_mock.assert();
|
|
start_mock.assert();
|
|
assert_eq!(output_stderr(&output), "");
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&output.stdout).trim(),
|
|
run_id.as_str()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detach_uses_configured_server_target_without_server_flag() {
|
|
let context = test_context!();
|
|
let server = MockServer::start();
|
|
let run_id = unique_run_id();
|
|
let create_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 start_mock = server.mock(|when, then| {
|
|
when.method("POST")
|
|
.path(format!("/api/v1/runs/{run_id}/start"));
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(run_status_response(run_id.as_str(), "queued").to_string());
|
|
});
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
|
);
|
|
|
|
let output = context
|
|
.run_cmd()
|
|
.args([
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
example_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)
|
|
);
|
|
create_mock.assert();
|
|
start_mock.assert();
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&output.stdout).trim(),
|
|
run_id.as_str()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detach_rejects_storage_dir_flag() {
|
|
let context = test_context!();
|
|
let output = context
|
|
.run_cmd()
|
|
.args([
|
|
"--storage-dir",
|
|
"/tmp/fabro-run",
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--no-retro",
|
|
example_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 detach_cli_server_target_overrides_configured_server_target() {
|
|
let context = test_context!();
|
|
let config_server = MockServer::start();
|
|
let config_create = config_server.mock(|when, then| {
|
|
when.method("POST").path("/api/v1/runs");
|
|
then.status(500)
|
|
.body("configured-server-should-not-be-used");
|
|
});
|
|
let config_start = config_server.mock(|when, then| {
|
|
when.method("POST").path_includes("/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 cli_create = 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());
|
|
});
|
|
let cli_start = cli_server.mock(|when, then| {
|
|
when.method("POST")
|
|
.path(format!("/api/v1/runs/{run_id}/start"));
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(run_status_response(run_id.as_str(), "queued").to_string());
|
|
});
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!(
|
|
"[server]\ntarget = \"{}/api/v1\"\n",
|
|
config_server.base_url()
|
|
),
|
|
);
|
|
|
|
let output = context
|
|
.run_cmd()
|
|
.args([
|
|
"--server",
|
|
&format!("{}/api/v1", cli_server.base_url()),
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
example_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)
|
|
);
|
|
cli_create.assert();
|
|
cli_start.assert();
|
|
config_create.assert_calls(0);
|
|
config_start.assert_calls(0);
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&output.stdout).trim(),
|
|
run_id.as_str()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_foreground_run_consumes_paginated_events_and_prints_server_backed_summary() {
|
|
let context = test_context!();
|
|
let server = MockServer::start();
|
|
let run_id = unique_run_id();
|
|
server.mock(|when, then| {
|
|
when.method("POST").path("/api/v1/preflight");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(preflight_response().to_string());
|
|
});
|
|
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());
|
|
});
|
|
server.mock(|when, then| {
|
|
when.method("POST")
|
|
.path(format!("/api/v1/runs/{run_id}/start"));
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(run_status_response(run_id.as_str(), "queued").to_string());
|
|
});
|
|
let first_page = server.mock(|when, then| {
|
|
when.method("GET")
|
|
.path(format!("/api/v1/runs/{run_id}/events"))
|
|
.query_param_missing("since_seq");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
serde_json::json!({
|
|
"data": [run_running_event(run_id.as_str(), 1)],
|
|
"meta": { "has_more": true }
|
|
})
|
|
.to_string(),
|
|
);
|
|
});
|
|
let second_page = server.mock(|when, then| {
|
|
when.method("GET")
|
|
.path(format!("/api/v1/runs/{run_id}/events"))
|
|
.query_param("since_seq", "2");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
serde_json::json!({
|
|
"data": [run_completed_event(run_id.as_str())],
|
|
"meta": { "has_more": false }
|
|
})
|
|
.to_string(),
|
|
);
|
|
});
|
|
server.mock(|when, then| {
|
|
when.method("GET")
|
|
.path(format!("/api/v1/runs/{run_id}/questions"))
|
|
.query_param("page[limit]", "100")
|
|
.query_param("page[offset]", "0");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
serde_json::json!({
|
|
"data": [],
|
|
"meta": { "has_more": false }
|
|
})
|
|
.to_string(),
|
|
);
|
|
});
|
|
server.mock(|when, then| {
|
|
when.method("GET")
|
|
.path(format!("/api/v1/runs/{run_id}/state"));
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(remote_run_state_response().to_string());
|
|
});
|
|
|
|
let output = context
|
|
.run_cmd()
|
|
.args([
|
|
"--server",
|
|
&format!("{}/api/v1", server.base_url()),
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
example_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)
|
|
);
|
|
first_page.assert();
|
|
second_page.assert();
|
|
|
|
let stderr = output_stderr(&output);
|
|
assert!(stderr.contains("=== Run Result ==="), "{stderr}");
|
|
assert!(stderr.contains("Remote output"), "{stderr}");
|
|
assert_eq!(
|
|
stderr
|
|
.lines()
|
|
.filter(|line| line.trim_start().starts_with("Run:"))
|
|
.count(),
|
|
1,
|
|
"{stderr}"
|
|
);
|
|
assert!(!stderr.contains("=== Artifacts ==="), "{stderr}");
|
|
}
|
|
|
|
#[test]
|
|
fn local_foreground_run_prints_artifact_paths_from_server_artifact_list() {
|
|
let context = test_context!();
|
|
let run_id = unique_run_id();
|
|
let workspace_dir = context.temp_dir.join("artifact-summary");
|
|
context.write_temp(
|
|
"artifact-summary/workflow.fabro",
|
|
r#"digraph ArtifactSummary {
|
|
graph [goal="Show stored artifacts"]
|
|
start [shape=Mdiamond]
|
|
exit [shape=Msquare]
|
|
create_assets [shape=parallelogram, script="mkdir -p assets/shared && printf one > assets/shared/report.txt"]
|
|
start -> create_assets -> exit
|
|
}
|
|
"#,
|
|
);
|
|
context.write_temp(
|
|
"artifact-summary/run.toml",
|
|
r#"version = 1
|
|
graph = "workflow.fabro"
|
|
goal = "Show stored artifacts"
|
|
|
|
[sandbox]
|
|
provider = "local"
|
|
preserve = true
|
|
|
|
[sandbox.local]
|
|
worktree_mode = "never"
|
|
|
|
[artifacts]
|
|
include = ["assets/**"]
|
|
"#,
|
|
);
|
|
|
|
let output = context
|
|
.run_cmd()
|
|
.current_dir(&workspace_dir)
|
|
.env("OPENAI_API_KEY", "test")
|
|
.args([
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
"--auto-approve",
|
|
"--no-retro",
|
|
"--sandbox",
|
|
"local",
|
|
"--provider",
|
|
"openai",
|
|
"run.toml",
|
|
])
|
|
.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 stderr = output_stderr(&output);
|
|
let expected_path = context
|
|
.find_run_dir(&run_id)
|
|
.join("cache/artifacts/files/create_assets/retry_1/assets/shared/report.txt");
|
|
assert!(stderr.contains("=== Artifacts ==="), "{stderr}");
|
|
assert!(
|
|
stderr.contains(expected_path.to_string_lossy().as_ref()),
|
|
"{stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dry_run_simple() {
|
|
let context = test_context!();
|
|
let mut cmd = context.run_cmd();
|
|
cmd.args(["--dry-run", "--auto-approve"]);
|
|
cmd.arg(example_fixture("simple.fabro"));
|
|
fabro_snapshot!(run_output_filters(&context), cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
----- stderr -----
|
|
Workflow: Simple (4 nodes, 3 edges)
|
|
Graph: [FIXTURES]/simple.fabro
|
|
Goal: Run tests and report results
|
|
|
|
Sandbox: local (ready in [TIME])
|
|
✓ Start [TIME]
|
|
✓ Run Tests [TIME]
|
|
✓ Report [TIME]
|
|
✓ Exit [TIME]
|
|
|
|
=== Run Result ===
|
|
Run: [ULID]
|
|
Status: SUCCESS
|
|
Duration: [DURATION]
|
|
Run: [RUN_DIR]
|
|
|
|
=== Output ===
|
|
[Simulated] Response for stage: report
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn dry_run_persists_event_history_in_store() {
|
|
let context = test_context!();
|
|
let run_id = unique_run_id();
|
|
|
|
context
|
|
.command()
|
|
.args([
|
|
"run",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--sandbox",
|
|
"local",
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
example_fixture("simple.fabro").to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
let run_dir = context.find_run_dir(&run_id);
|
|
wait_for_event_names(&run_dir, &["run.completed", "sandbox.cleanup.completed"]);
|
|
let output = context
|
|
.command()
|
|
.args(["logs", &run_id])
|
|
.output()
|
|
.expect("logs command should execute");
|
|
assert!(
|
|
output.status.success(),
|
|
"logs failed:\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let progress: Vec<Value> = String::from_utf8(output.stdout)
|
|
.expect("stdout should be UTF-8")
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).expect("logs output should be JSONL"))
|
|
.collect();
|
|
assert!(
|
|
!progress.is_empty(),
|
|
"store-backed event history should have at least one line"
|
|
);
|
|
assert_eq!(
|
|
progress.first().and_then(|event| event["event"].as_str()),
|
|
Some("run.created")
|
|
);
|
|
assert_eq!(
|
|
progress
|
|
.first()
|
|
.and_then(|event| event.pointer("/properties/settings/auto_approve"))
|
|
.and_then(Value::as_bool),
|
|
Some(true)
|
|
);
|
|
assert!(
|
|
progress
|
|
.iter()
|
|
.any(|event| event["event"].as_str() == Some("run.completed")),
|
|
"store-backed event history should include run.completed"
|
|
);
|
|
assert_eq!(
|
|
progress.last().and_then(|event| event["event"].as_str()),
|
|
Some("sandbox.cleanup.completed")
|
|
);
|
|
|
|
let tail_output = context
|
|
.command()
|
|
.args(["logs", "--tail", "1", &run_id])
|
|
.output()
|
|
.expect("tail logs command should execute");
|
|
assert!(
|
|
tail_output.status.success(),
|
|
"tail logs failed:\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&tail_output.stdout),
|
|
String::from_utf8_lossy(&tail_output.stderr)
|
|
);
|
|
let live_content: Value = String::from_utf8(tail_output.stdout)
|
|
.expect("stdout should be UTF-8")
|
|
.lines()
|
|
.find(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).expect("tail logs output should be JSON"))
|
|
.expect("tail logs should include the latest event");
|
|
fabro_json_snapshot!(context, &live_content, @r#"
|
|
{
|
|
"event": "sandbox.cleanup.completed",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"duration_ms": "[DURATION_MS]",
|
|
"provider": "local"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
}
|
|
"#);
|
|
|
|
assert_eq!(live_content, *progress.last().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn run_id_passthrough_uses_provided_ulid() {
|
|
let context = test_context!();
|
|
let run_id = unique_run_id();
|
|
|
|
context
|
|
.command()
|
|
.args([
|
|
"run",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
example_fixture("simple.fabro").to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
context.find_run_dir(&run_id);
|
|
}
|
|
|
|
#[test]
|
|
fn json_run_implies_auto_approve_for_human_gates() {
|
|
let context = test_context!();
|
|
let workflow = context.temp_dir.join("human-gate.fabro");
|
|
context.write_temp(
|
|
"human-gate.fabro",
|
|
r#"digraph HumanGate {
|
|
graph [goal="Route through the default approval path"]
|
|
start [shape=Mdiamond, label="Start"]
|
|
exit [shape=Msquare, label="Exit"]
|
|
approve [shape=hexagon, label="Approve?"]
|
|
ship [shape=parallelogram, script="echo shipped"]
|
|
revise [shape=parallelogram, script="echo revised"]
|
|
start -> approve
|
|
approve -> ship [label="[A] Approve"]
|
|
approve -> revise [label="[R] Revise"]
|
|
ship -> exit
|
|
revise -> exit
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let output = context
|
|
.command()
|
|
.args([
|
|
"--json",
|
|
"run",
|
|
"--sandbox",
|
|
"local",
|
|
"--no-retro",
|
|
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 mut progress: Vec<Value> = String::from_utf8(output.stdout)
|
|
.expect("stdout should be UTF-8")
|
|
.lines()
|
|
.filter(|line| !line.trim().is_empty())
|
|
.map(|line| serde_json::from_str(line).expect("run JSON output should be JSONL"))
|
|
.collect();
|
|
for event in &mut progress {
|
|
if let Some(properties) = event.get_mut("properties").and_then(Value::as_object_mut) {
|
|
if properties.contains_key("manifest_blob") {
|
|
properties.insert(
|
|
"manifest_blob".to_string(),
|
|
Value::String("[BLOB_ID]".to_string()),
|
|
);
|
|
}
|
|
if properties.contains_key("definition_blob") {
|
|
properties.insert(
|
|
"definition_blob".to_string(),
|
|
Value::String("[BLOB_ID]".to_string()),
|
|
);
|
|
}
|
|
}
|
|
let Some(llm) = event.pointer_mut("/properties/settings/llm") else {
|
|
continue;
|
|
};
|
|
let Some(llm) = llm.as_object_mut() else {
|
|
continue;
|
|
};
|
|
llm.insert(
|
|
"model".to_string(),
|
|
Value::String("[LLM_MODEL]".to_string()),
|
|
);
|
|
llm.insert(
|
|
"provider".to_string(),
|
|
Value::String("[LLM_PROVIDER]".to_string()),
|
|
);
|
|
}
|
|
fabro_json_snapshot!(context, &progress, @r#"
|
|
[
|
|
{
|
|
"event": "run.created",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"graph": {
|
|
"attrs": {
|
|
"goal": {
|
|
"String": "Route through the default approval path"
|
|
}
|
|
},
|
|
"edges": [
|
|
{
|
|
"attrs": {},
|
|
"from": "start",
|
|
"to": "approve"
|
|
},
|
|
{
|
|
"attrs": {
|
|
"label": {
|
|
"String": "[A] Approve"
|
|
}
|
|
},
|
|
"from": "approve",
|
|
"to": "ship"
|
|
},
|
|
{
|
|
"attrs": {
|
|
"label": {
|
|
"String": "[R] Revise"
|
|
}
|
|
},
|
|
"from": "approve",
|
|
"to": "revise"
|
|
},
|
|
{
|
|
"attrs": {},
|
|
"from": "ship",
|
|
"to": "exit"
|
|
},
|
|
{
|
|
"attrs": {},
|
|
"from": "revise",
|
|
"to": "exit"
|
|
}
|
|
],
|
|
"name": "HumanGate",
|
|
"nodes": {
|
|
"approve": {
|
|
"attrs": {
|
|
"label": {
|
|
"String": "Approve?"
|
|
},
|
|
"shape": {
|
|
"String": "hexagon"
|
|
}
|
|
},
|
|
"id": "approve"
|
|
},
|
|
"exit": {
|
|
"attrs": {
|
|
"label": {
|
|
"String": "Exit"
|
|
},
|
|
"shape": {
|
|
"String": "Msquare"
|
|
}
|
|
},
|
|
"id": "exit"
|
|
},
|
|
"revise": {
|
|
"attrs": {
|
|
"script": {
|
|
"String": "echo revised"
|
|
},
|
|
"shape": {
|
|
"String": "parallelogram"
|
|
}
|
|
},
|
|
"id": "revise"
|
|
},
|
|
"ship": {
|
|
"attrs": {
|
|
"script": {
|
|
"String": "echo shipped"
|
|
},
|
|
"shape": {
|
|
"String": "parallelogram"
|
|
}
|
|
},
|
|
"id": "ship"
|
|
},
|
|
"start": {
|
|
"attrs": {
|
|
"label": {
|
|
"String": "Start"
|
|
},
|
|
"shape": {
|
|
"String": "Mdiamond"
|
|
}
|
|
},
|
|
"id": "start"
|
|
}
|
|
}
|
|
},
|
|
"host_repo_path": "[TEMP_DIR]",
|
|
"manifest_blob": "[BLOB_ID]",
|
|
"provenance": {
|
|
"client": {
|
|
"name": "fabro-cli",
|
|
"user_agent": "fabro-cli/0.176.2",
|
|
"version": "0.176.2"
|
|
},
|
|
"server": {
|
|
"version": "0.176.2"
|
|
},
|
|
"subject": {
|
|
"auth_method": "disabled"
|
|
}
|
|
},
|
|
"run_dir": "[RUN_DIR]",
|
|
"settings": {
|
|
"auto_approve": true,
|
|
"goal": "Route through the default approval path",
|
|
"llm": {
|
|
"fallbacks": null,
|
|
"model": "[LLM_MODEL]",
|
|
"provider": "[LLM_PROVIDER]"
|
|
},
|
|
"no_retro": true,
|
|
"sandbox": {
|
|
"daytona": null,
|
|
"devcontainer": null,
|
|
"env": null,
|
|
"local": null,
|
|
"preserve": null,
|
|
"provider": "local"
|
|
},
|
|
"storage_dir": "[STORAGE_DIR]"
|
|
},
|
|
"workflow_slug": "human-gate",
|
|
"workflow_source": "digraph HumanGate {/n graph [goal=\"Route through the default approval path\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n",
|
|
"working_directory": "[TEMP_DIR]"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "run.submitted",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"definition_blob": "[BLOB_ID]"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "run.starting",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"reason": "sandbox_initializing"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "sandbox.initializing",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"provider": "local"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "sandbox.ready",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"duration_ms": "[DURATION_MS]",
|
|
"provider": "local"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "sandbox.initialized",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"provider": "local",
|
|
"working_directory": "[TEMP_DIR]"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "run.started",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"goal": "Route through the default approval path",
|
|
"name": "HumanGate"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "run.running",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "stage.started",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "start",
|
|
"node_label": "Start",
|
|
"properties": {
|
|
"attempt": 1,
|
|
"handler_type": "start",
|
|
"index": 0,
|
|
"max_attempts": 1
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "stage.completed",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "start",
|
|
"node_label": "Start",
|
|
"properties": {
|
|
"attempt": 1,
|
|
"context_values": {
|
|
"current.preamble": "Goal: Route through the default approval path/n",
|
|
"current_node": "start",
|
|
"graph.goal": "Route through the default approval path",
|
|
"internal.fidelity": "compact",
|
|
"internal.node_visit_count": 1,
|
|
"internal.run_id": "[ULID]",
|
|
"internal.thread_id": null
|
|
},
|
|
"duration_ms": "[DURATION_MS]",
|
|
"index": 0,
|
|
"max_attempts": 1,
|
|
"node_visits": {
|
|
"start": 1
|
|
},
|
|
"status": "success"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "edge.selected",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"from_node": "start",
|
|
"is_jump": false,
|
|
"reason": "unconditional",
|
|
"stage_status": "success",
|
|
"to_node": "approve"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "checkpoint.completed",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "start",
|
|
"node_label": "start",
|
|
"properties": {
|
|
"completed_nodes": [
|
|
"start"
|
|
],
|
|
"context_values": {
|
|
"current_node": "start",
|
|
"failure_class": "",
|
|
"failure_signature": "",
|
|
"graph.goal": "Route through the default approval path",
|
|
"internal.fidelity": "compact",
|
|
"internal.node_visit_count": 1,
|
|
"internal.retry_count.start": 0,
|
|
"internal.run_id": "[ULID]",
|
|
"internal.thread_id": null,
|
|
"outcome": "success"
|
|
},
|
|
"current_node": "start",
|
|
"next_node_id": "approve",
|
|
"node_outcomes": {
|
|
"start": {
|
|
"status": "success",
|
|
"usage": null
|
|
}
|
|
},
|
|
"node_visits": {
|
|
"start": 1
|
|
},
|
|
"status": "success"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "stage.started",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "approve",
|
|
"node_label": "Approve?",
|
|
"properties": {
|
|
"attempt": 1,
|
|
"handler_type": "human",
|
|
"index": 1,
|
|
"max_attempts": 1
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "interview.started",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "approve",
|
|
"node_label": "approve",
|
|
"properties": {
|
|
"allow_freeform": false,
|
|
"options": [
|
|
{
|
|
"key": "A",
|
|
"label": "[A] Approve"
|
|
},
|
|
{
|
|
"key": "R",
|
|
"label": "[R] Revise"
|
|
}
|
|
],
|
|
"question": "Approve?",
|
|
"question_id": "[ULID]",
|
|
"question_type": "multiple_choice",
|
|
"stage": "approve"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "interview.completed",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"answer": "A",
|
|
"duration_ms": "[DURATION_MS]",
|
|
"question": "Approve?",
|
|
"question_id": "[ULID]"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "stage.completed",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "approve",
|
|
"node_label": "Approve?",
|
|
"properties": {
|
|
"attempt": 1,
|
|
"context_updates": {
|
|
"human.gate.label": "[A] Approve",
|
|
"human.gate.selected": "A"
|
|
},
|
|
"context_values": {
|
|
"current.preamble": "Goal: Route through the default approval path/n",
|
|
"current_node": "approve",
|
|
"failure_class": "",
|
|
"failure_signature": "",
|
|
"graph.goal": "Route through the default approval path",
|
|
"internal.fidelity": "compact",
|
|
"internal.node_visit_count": 1,
|
|
"internal.retry_count.start": 0,
|
|
"internal.run_id": "[ULID]",
|
|
"internal.thread_id": "start",
|
|
"outcome": "success",
|
|
"thread.start.current_node": "approve"
|
|
},
|
|
"duration_ms": "[DURATION_MS]",
|
|
"index": 1,
|
|
"max_attempts": 1,
|
|
"node_visits": {
|
|
"approve": 1,
|
|
"start": 1
|
|
},
|
|
"preferred_label": "[A] Approve",
|
|
"status": "success",
|
|
"suggested_next_ids": [
|
|
"ship"
|
|
]
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "edge.selected",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"from_node": "approve",
|
|
"is_jump": false,
|
|
"label": "[A] Approve",
|
|
"preferred_label": "[A] Approve",
|
|
"reason": "preferred_label",
|
|
"stage_status": "success",
|
|
"suggested_next_ids": [
|
|
"ship"
|
|
],
|
|
"to_node": "ship"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "checkpoint.completed",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "approve",
|
|
"node_label": "approve",
|
|
"properties": {
|
|
"completed_nodes": [
|
|
"start",
|
|
"approve"
|
|
],
|
|
"context_values": {
|
|
"current_node": "approve",
|
|
"failure_class": "",
|
|
"failure_signature": "",
|
|
"graph.goal": "Route through the default approval path",
|
|
"human.gate.label": "[A] Approve",
|
|
"human.gate.selected": "A",
|
|
"internal.fidelity": "compact",
|
|
"internal.node_visit_count": 1,
|
|
"internal.retry_count.approve": 0,
|
|
"internal.retry_count.start": 0,
|
|
"internal.run_id": "[ULID]",
|
|
"internal.thread_id": "start",
|
|
"outcome": "success",
|
|
"preferred_label": "[A] Approve",
|
|
"thread.start.current_node": "approve"
|
|
},
|
|
"current_node": "approve",
|
|
"next_node_id": "ship",
|
|
"node_outcomes": {
|
|
"approve": {
|
|
"context_updates": {
|
|
"human.gate.label": "[A] Approve",
|
|
"human.gate.selected": "A"
|
|
},
|
|
"preferred_label": "[A] Approve",
|
|
"status": "success",
|
|
"suggested_next_ids": [
|
|
"ship"
|
|
],
|
|
"usage": null
|
|
},
|
|
"start": {
|
|
"status": "success",
|
|
"usage": null
|
|
}
|
|
},
|
|
"node_visits": {
|
|
"approve": 1,
|
|
"start": 1
|
|
},
|
|
"status": "success"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "stage.started",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "ship",
|
|
"node_label": "ship",
|
|
"properties": {
|
|
"attempt": 1,
|
|
"handler_type": "command",
|
|
"index": 2,
|
|
"max_attempts": 1
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "command.started",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "ship",
|
|
"node_label": "ship",
|
|
"properties": {
|
|
"command": "echo shipped",
|
|
"language": "shell",
|
|
"script": "echo shipped"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "command.completed",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "ship",
|
|
"node_label": "ship",
|
|
"properties": {
|
|
"duration_ms": "[DURATION_MS]",
|
|
"exit_code": 0,
|
|
"stderr": "",
|
|
"stdout": "shipped/n",
|
|
"timed_out": false
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "stage.completed",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "ship",
|
|
"node_label": "ship",
|
|
"properties": {
|
|
"attempt": 1,
|
|
"context_updates": {
|
|
"command.output": "shipped/n",
|
|
"command.stderr": ""
|
|
},
|
|
"context_values": {
|
|
"current.preamble": "Goal: Route through the default approval path/n/n## Completed stages/n- **approve**: success/n/n## Context/n- human.gate.label: [A] Approve/n- human.gate.selected: A/n",
|
|
"current_node": "ship",
|
|
"failure_class": "",
|
|
"failure_signature": "",
|
|
"graph.goal": "Route through the default approval path",
|
|
"human.gate.label": "[A] Approve",
|
|
"human.gate.selected": "A",
|
|
"internal.fidelity": "compact",
|
|
"internal.node_visit_count": 1,
|
|
"internal.retry_count.approve": 0,
|
|
"internal.retry_count.start": 0,
|
|
"internal.run_id": "[ULID]",
|
|
"internal.thread_id": "approve",
|
|
"outcome": "success",
|
|
"preferred_label": "[A] Approve",
|
|
"thread.approve.current_node": "ship",
|
|
"thread.start.current_node": "approve"
|
|
},
|
|
"duration_ms": "[DURATION_MS]",
|
|
"index": 2,
|
|
"max_attempts": 1,
|
|
"node_visits": {
|
|
"approve": 1,
|
|
"ship": 1,
|
|
"start": 1
|
|
},
|
|
"notes": "Script completed: echo shipped",
|
|
"status": "success"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "edge.selected",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"from_node": "ship",
|
|
"is_jump": false,
|
|
"reason": "unconditional",
|
|
"stage_status": "success",
|
|
"to_node": "exit"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "checkpoint.completed",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "ship",
|
|
"node_label": "ship",
|
|
"properties": {
|
|
"completed_nodes": [
|
|
"start",
|
|
"approve",
|
|
"ship"
|
|
],
|
|
"context_values": {
|
|
"command.output": "shipped/n",
|
|
"command.stderr": "",
|
|
"current_node": "ship",
|
|
"failure_class": "",
|
|
"failure_signature": "",
|
|
"graph.goal": "Route through the default approval path",
|
|
"human.gate.label": "[A] Approve",
|
|
"human.gate.selected": "A",
|
|
"internal.fidelity": "compact",
|
|
"internal.node_visit_count": 1,
|
|
"internal.retry_count.approve": 0,
|
|
"internal.retry_count.ship": 0,
|
|
"internal.retry_count.start": 0,
|
|
"internal.run_id": "[ULID]",
|
|
"internal.thread_id": "approve",
|
|
"outcome": "success",
|
|
"preferred_label": "[A] Approve",
|
|
"thread.approve.current_node": "ship",
|
|
"thread.start.current_node": "approve"
|
|
},
|
|
"current_node": "ship",
|
|
"next_node_id": "exit",
|
|
"node_outcomes": {
|
|
"approve": {
|
|
"context_updates": {
|
|
"human.gate.label": "[A] Approve",
|
|
"human.gate.selected": "A"
|
|
},
|
|
"preferred_label": "[A] Approve",
|
|
"status": "success",
|
|
"suggested_next_ids": [
|
|
"ship"
|
|
],
|
|
"usage": null
|
|
},
|
|
"ship": {
|
|
"context_updates": {
|
|
"command.output": "shipped/n",
|
|
"command.stderr": ""
|
|
},
|
|
"notes": "Script completed: echo shipped",
|
|
"status": "success",
|
|
"usage": null
|
|
},
|
|
"start": {
|
|
"status": "success",
|
|
"usage": null
|
|
}
|
|
},
|
|
"node_visits": {
|
|
"approve": 1,
|
|
"ship": 1,
|
|
"start": 1
|
|
},
|
|
"status": "success"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "stage.started",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "exit",
|
|
"node_label": "Exit",
|
|
"properties": {
|
|
"attempt": 1,
|
|
"handler_type": "exit",
|
|
"index": 3,
|
|
"max_attempts": 1
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "stage.completed",
|
|
"id": "[EVENT_ID]",
|
|
"node_id": "exit",
|
|
"node_label": "Exit",
|
|
"properties": {
|
|
"attempt": 1,
|
|
"duration_ms": "[DURATION_MS]",
|
|
"index": 3,
|
|
"max_attempts": 1,
|
|
"status": "success"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
},
|
|
{
|
|
"event": "run.completed",
|
|
"id": "[EVENT_ID]",
|
|
"properties": {
|
|
"artifact_count": 0,
|
|
"duration_ms": "[DURATION_MS]",
|
|
"reason": "completed",
|
|
"status": "success"
|
|
},
|
|
"run_id": "[ULID]",
|
|
"ts": "[TIMESTAMP]"
|
|
}
|
|
]
|
|
"#);
|
|
|
|
assert_eq!(
|
|
progress[0].pointer("/properties/settings/auto_approve"),
|
|
Some(&serde_json::json!(true))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detach_prints_ulid_and_exits() {
|
|
let context = test_context!();
|
|
let mut cmd = context.run_cmd();
|
|
cmd.args([
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
example_fixture("simple.fabro").to_str().unwrap(),
|
|
]);
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
[ULID]
|
|
----- stderr -----
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn detach_creates_run_dir_with_detach_log() {
|
|
let context = test_context!();
|
|
let run_id = unique_run_id();
|
|
|
|
context
|
|
.run_cmd()
|
|
.args([
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--run-id",
|
|
run_id.as_str(),
|
|
example_fixture("simple.fabro").to_str().unwrap(),
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
let run_dir = context.find_run_dir(&run_id);
|
|
fabro_json_snapshot!(
|
|
context,
|
|
serde_json::json!({
|
|
"run_dir": run_dir,
|
|
"launcher_log_exists": context.storage_dir.join("launchers").join(format!("{run_id}.log")).exists(),
|
|
}),
|
|
@r#"
|
|
{
|
|
"run_dir": "[RUN_DIR]",
|
|
"launcher_log_exists": false
|
|
}
|
|
"#
|
|
);
|
|
}
|