mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
Replace the overlapping usage and cost model with canonical billing primitives centered on ModelRef, ModelHandle, TokenCounts, and BilledModelUsage. This also renames the public API and web surface from usage to billing, removes compatibility aliases, and normalizes provider usage adapters onto the shared billing vocabulary.
252 lines
7.8 KiB
Rust
252 lines
7.8 KiB
Rust
use fabro_test::{fabro_snapshot, test_context};
|
|
use httpmock::MockServer;
|
|
use serde_json::Value;
|
|
|
|
use crate::support::unique_run_id;
|
|
|
|
use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run};
|
|
|
|
#[test]
|
|
fn help() {
|
|
let context = test_context!();
|
|
let mut cmd = context.command();
|
|
cmd.args(["ps", "--help"]);
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
List workflow runs
|
|
|
|
Usage: fabro ps [OPTIONS]
|
|
|
|
Options:
|
|
--json Output as JSON [env: FABRO_JSON=]
|
|
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
|
|
--before <BEFORE> Only include runs started before this date (YYYY-MM-DD prefix match)
|
|
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
|
|
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
|
|
--workflow <WORKFLOW> Filter by workflow name (substring match)
|
|
--label <KEY=VALUE> Filter by label (KEY=VALUE, repeatable, AND semantics)
|
|
--orphans Include orphan directories (no run.json)
|
|
--verbose Enable verbose output [env: FABRO_VERBOSE=]
|
|
-a, --all Show all runs, not just running (like docker ps -a)
|
|
-q, --quiet Only display run IDs
|
|
-h, --help Print help
|
|
----- stderr -----
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn ps_default_excludes_non_running_runs() {
|
|
let context = test_context!();
|
|
setup_completed_fast_dry_run(&context);
|
|
let mut cmd = context.ps();
|
|
cmd.args(["--label", &context.test_case_label()]);
|
|
|
|
fabro_snapshot!(context.filters(), cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
----- stderr -----
|
|
No running processes found. Use -a to show all runs.
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn ps_all_json_lists_created_and_completed_runs() {
|
|
let context = test_context!();
|
|
setup_completed_fast_dry_run(&context);
|
|
setup_created_fast_dry_run(&context);
|
|
let output = context
|
|
.ps()
|
|
.args(["-a", "--json", "--label", &context.test_case_label()])
|
|
.output()
|
|
.expect("ps should run");
|
|
|
|
assert!(output.status.success(), "ps should succeed");
|
|
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
|
assert_eq!(runs.len(), 2, "expected submitted + completed runs");
|
|
assert!(
|
|
runs.iter().all(|run| run["workflow_name"] == "Simple"),
|
|
"all runs should belong to the Simple workflow: {runs:#?}"
|
|
);
|
|
assert!(
|
|
runs.iter()
|
|
.all(|run| run["labels"]["fabro_test_case"] == context.test_case_id()),
|
|
"all runs should be scoped to the current test case: {runs:#?}"
|
|
);
|
|
assert!(
|
|
runs.iter()
|
|
.all(|run| run["labels"]["fabro_test_run"] == context.test_run_id()),
|
|
"all runs should be scoped to the current test session: {runs:#?}"
|
|
);
|
|
assert!(
|
|
runs.iter().any(|run| run["status"] == "submitted"),
|
|
"ps should include the created run: {runs:#?}"
|
|
);
|
|
assert!(
|
|
runs.iter().any(|run| run["status"] == "succeeded"),
|
|
"ps should include the completed run: {runs:#?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn setup_completed_fast_dry_run_preserves_handle_when_another_run_exists() {
|
|
let context = test_context!();
|
|
|
|
let created = setup_created_fast_dry_run(&context);
|
|
let completed = setup_completed_fast_dry_run(&context);
|
|
|
|
assert_ne!(created.run_id, completed.run_id);
|
|
assert_ne!(created.run_dir, completed.run_dir);
|
|
assert!(created.run_dir.exists(), "created run dir should exist");
|
|
assert!(completed.run_dir.exists(), "completed run dir should exist");
|
|
}
|
|
|
|
#[test]
|
|
fn ps_quiet_outputs_run_ids_only() {
|
|
let context = test_context!();
|
|
setup_completed_fast_dry_run(&context);
|
|
setup_created_fast_dry_run(&context);
|
|
let mut cmd = context.ps();
|
|
cmd.args(["-a", "--quiet", "--label", &context.test_case_label()]);
|
|
|
|
fabro_snapshot!(context.filters(), cmd, @r###"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
[ULID]
|
|
[ULID]
|
|
----- stderr -----
|
|
"###);
|
|
}
|
|
|
|
#[test]
|
|
fn ps_filters_by_workflow_and_label() {
|
|
let context = test_context!();
|
|
let simple = context.temp_dir.join("simple.fabro");
|
|
let branching = context.temp_dir.join("branching.fabro");
|
|
context.write_temp(
|
|
"simple.fabro",
|
|
r#"digraph Simple {
|
|
start [shape=Mdiamond]
|
|
exit [shape=Msquare]
|
|
run [shape=parallelogram, script="true"]
|
|
start -> run -> exit
|
|
}
|
|
"#,
|
|
);
|
|
context.write_temp(
|
|
"branching.fabro",
|
|
r#"digraph Branching {
|
|
start [shape=Mdiamond]
|
|
exit [shape=Msquare]
|
|
run [shape=parallelogram, script="true"]
|
|
start -> run -> exit
|
|
}
|
|
"#,
|
|
);
|
|
|
|
context
|
|
.run_cmd()
|
|
.args([
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--no-retro",
|
|
"--label",
|
|
"suite=alpha",
|
|
])
|
|
.arg(&simple)
|
|
.assert()
|
|
.success();
|
|
context
|
|
.create_cmd()
|
|
.args([
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--no-retro",
|
|
"--label",
|
|
"suite=beta",
|
|
])
|
|
.arg(&branching)
|
|
.assert()
|
|
.success();
|
|
|
|
let output = context
|
|
.ps()
|
|
.args([
|
|
"-a",
|
|
"--json",
|
|
"--workflow",
|
|
"Simple",
|
|
"--label",
|
|
"suite=alpha",
|
|
"--label",
|
|
&context.test_case_label(),
|
|
])
|
|
.output()
|
|
.expect("ps should run");
|
|
|
|
assert!(output.status.success(), "ps should succeed");
|
|
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
|
assert_eq!(
|
|
runs.len(),
|
|
1,
|
|
"workflow+label filter should isolate one run"
|
|
);
|
|
let run = &runs[0];
|
|
assert_eq!(run["workflow_name"], "Simple");
|
|
assert_eq!(run["status"], "succeeded");
|
|
assert_eq!(run["labels"]["suite"], "alpha");
|
|
assert_eq!(run["labels"]["fabro_test_case"], context.test_case_id());
|
|
assert_eq!(run["labels"]["fabro_test_run"], context.test_run_id());
|
|
}
|
|
|
|
#[test]
|
|
fn ps_uses_configured_server_target_without_server_flag() {
|
|
let context = test_context!();
|
|
let server = MockServer::start();
|
|
let run_id = unique_run_id();
|
|
let mock = server.mock(|when, then| {
|
|
when.method("GET").path("/api/v1/runs");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
serde_json::json!([
|
|
{
|
|
"run_id": run_id,
|
|
"workflow_name": "Remote Workflow",
|
|
"workflow_slug": "remote-workflow",
|
|
"goal": "Remote goal",
|
|
"labels": {
|
|
"suite": "remote"
|
|
},
|
|
"host_repo_path": "/srv/repo",
|
|
"start_time": "2026-04-05T12:00:00Z",
|
|
"status": "succeeded",
|
|
"status_reason": null,
|
|
"duration_ms": 123,
|
|
"total_usd_micros": null
|
|
}
|
|
])
|
|
.to_string(),
|
|
);
|
|
});
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()),
|
|
);
|
|
|
|
let output = context
|
|
.ps()
|
|
.args(["-a", "--json"])
|
|
.output()
|
|
.expect("ps should execute");
|
|
|
|
assert!(output.status.success(), "ps should succeed");
|
|
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
|
mock.assert();
|
|
assert_eq!(runs.len(), 1);
|
|
assert_eq!(runs[0]["workflow_name"], "Remote Workflow");
|
|
assert_eq!(runs[0]["host_repo_path"], "/srv/repo");
|
|
}
|