fabro/lib/apps/fabro-cli/tests/it/cmd/inspect.rs
Bryan Helmkamp 0d74fdf01d
Port the CLI tests to Petri runs and the run stream
The CLI's integration tests seeded runs by appending legacy run events
and waited on legacy event names. Now every seeded run is a real dry
run: the fixtures start the run through the CLI, read the run id from
its output and wait for the stream's terminal lifecycle record. Waits,
assertions and snapshots read `RunStreamItem`s (`run.finished`, the
platform `run.lifecycle` record, `derived.parsed.kind == "question"`).

Test changes:
- support.rs: `run_completed_dry_run`, `wait_for_run_finished`,
  `wait_for_lifecycle`, `wait_for_stream_item`; the `append_seeded_*`
  writers, `wait_for_event_names` and the git-backed seeded fixtures
  are gone (the checkpoint patch is not in the projection yet).
- diff.rs keeps only the help test; inspect.rs drops the git-backed
  checkpoint test; events.rs, dump.rs, create.rs, attach.rs and
  dry_run_examples.rs snapshots are re-recorded over Petri's rendering
  with redactions for epoch millis, digests and commit shas.
- run.rs: the remote foreground mock serves stream pages and a run
  state with a conclusion and a `report` stage response; the event
  history test checks `run.finished` and the terminal lifecycle item.
- runner.rs / attach.rs: question ids containing `#` are percent-encoded
  in answer URLs.

Production fixes the ports surfaced:
- petri_worker.rs: a cancelled run exits without reporting a failure.
- runner.rs: resuming a run that already finished fails its precondition
  instead of starting a worker.

Left failing on purpose, each bound to a Petri-side gap reported to the
lead rather than to the port: sandbox_cp (4), sandbox_preview and
sandbox_ssh (the projection carries no sandbox instance), the artifact
collection tests in workflow::artifacts and run.rs (no artifact
collection for Petri runs yet), and the two dump blob-ref tests (blob
refs are not visible in the inspect output).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:30:18 -04:00

471 lines
13 KiB
Rust

use fabro_test::{fabro_snapshot, test_context};
use httpmock::MockServer;
use insta::assert_snapshot;
use serde_json::{Value, json};
use super::support::{
compact_inspect, remote_run_summary_json, run_success, setup_seeded_completed_dry_run,
setup_seeded_created_dry_run,
};
use crate::support::{run_projection_json, unique_run_id};
fn remote_run_summary(run_id: &str, status: &serde_json::Value) -> serde_json::Value {
remote_run_summary_json(
run_id,
"Nightly Build",
"nightly-build",
"Inspect remote state",
status,
"2026-04-19T12:00:00Z",
)
}
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["inspect", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Show detailed information about a workflow run
Usage: fabro inspect [OPTIONS] <RUN>
Arguments:
<RUN> Run ID prefix or workflow name (most recent run)
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=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn inspect_resolves_selector_via_server_endpoint() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let summary = remote_run_summary(
run_id.as_str(),
&json!({
"kind": "succeeded",
"reason": "completed"
}),
);
let resolve_run = server.mock(|when, then| {
when.method("GET")
.path("/api/v1/runs/resolve")
.query_param("selector", "nightly-build");
then.status(200)
.header("content-type", "application/json")
.body(summary.to_string());
});
let run_state = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{}/state", run_id.as_str()));
then.status(200)
.header("content-type", "application/json")
.body(
run_projection_json(
run_id.as_str(),
&json!({
"kind": "succeeded",
"reason": "completed"
}),
)
.to_string(),
);
});
let mut cmd = context.command();
cmd.args([
"inspect",
"--server",
&format!("{}/api/v1", server.base_url()),
"nightly-build",
]);
fabro_snapshot!(context.filters(), cmd, @r#"
success: true
exit_code: 0
----- stdout -----
[
{
"run_id": "[ULID]",
"parent_id": null,
"status": {
"kind": "succeeded",
"reason": "completed"
},
"run_spec": {
"run_id": "[ULID]",
"settings": {
"project": {
"name": null,
"description": null,
"metadata": {}
},
"workflow": {
"name": null,
"description": null,
"graph": "",
"metadata": {}
},
"run": {
"goal": null,
"working_dir": null,
"metadata": {},
"inputs": {},
"model": {
"provider": null,
"name": null,
"fallbacks": {},
"controls": {
"reasoning_effort": null,
"speed": null
}
},
"git": {
"author": null
},
"prepare": {
"steps": [],
"timeout_ms": 300000
},
"execution": {
"mode": "normal",
"approval": "prompt"
},
"checkpoint": {
"exclude_globs": [],
"skip_git_hooks": false,
"commit_timeout_ms": 30000
},
"clone": {
"enabled": true,
"depth": 100
},
"run_branch": {
"enabled": true,
"push": true
},
"environment": {
"id": "default",
"provider": "local",
"image": {
"docker": null,
"dockerfile": null
},
"resources": {
"cpu": null,
"memory": null,
"disk": null
},
"network": {
"mode": "allow_all",
"allow": []
},
"lifecycle": {
"preserve": false,
"stop_on_terminal": true,
"auto_stop": null
},
"labels": {},
"env": {}
},
"notifications": {},
"interviews": {
"provider": null,
"slack": null
},
"agent": {
"fabro_tools": false,
"mcps": {}
},
"hooks": [],
"scm": {
"provider": null,
"owner": null,
"repository": null,
"github": null
},
"pull_request": null,
"artifacts": {
"include": []
},
"integrations": {
"github": {
"permissions": {}
}
}
}
},
"graph": {
"name": "Remote Workflow",
"nodes": {},
"edges": [],
"attrs": {}
},
"workflow_slug": "remote-workflow",
"source_directory": "/srv/repo",
"provenance": {
"subject": {
"kind": "user",
"identity": {
"issuer": "fabro:test",
"subject": "test-user"
},
"login": "test",
"auth_method": "dev_token"
}
},
"admission": {
"graph": {
"blob": "e6e4557838b761a195536fb5ca1f2c13a1a21b17260cf7376c494fcc4b8c6c57",
"digest": "sha256:test-admission"
}
}
},
"start_record": null,
"conclusion": null,
"checkpoint": null,
"sandbox": null
}
]
----- stderr -----
"#);
resolve_run.assert();
run_state.assert();
}
#[test]
fn inspect_includes_parent_id_from_run_projection() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let parent_id = unique_run_id();
let summary = remote_run_summary(
run_id.as_str(),
&json!({
"kind": "succeeded",
"reason": "completed"
}),
);
let resolve_run = server.mock(|when, then| {
when.method("GET")
.path("/api/v1/runs/resolve")
.query_param("selector", "nightly-build");
then.status(200)
.header("content-type", "application/json")
.body(summary.to_string());
});
let run_state = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{}/state", run_id.as_str()));
let mut state = run_projection_json(
run_id.as_str(),
&json!({
"kind": "succeeded",
"reason": "completed"
}),
);
state["parent_id"] = json!(parent_id);
then.status(200)
.header("content-type", "application/json")
.body(state.to_string());
});
let output = context
.command()
.args([
"inspect",
"--server",
&format!("{}/api/v1", server.base_url()),
"nightly-build",
])
.output()
.expect("inspect should execute");
assert!(
output.status.success(),
"inspect failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let items: Value = serde_json::from_slice(&output.stdout).expect("inspect JSON should parse");
assert_eq!(items[0]["run_id"], run_id);
assert_eq!(items[0]["parent_id"], parent_id);
resolve_run.assert();
run_state.assert();
}
#[test]
fn inspect_created_run_shows_run_spec_without_start_or_conclusion() {
let context = test_context!();
let run = setup_seeded_created_dry_run(&context);
let output = run_success(&context, &["inspect", &run.run_id]);
assert_snapshot!(serde_json::to_string_pretty(&compact_inspect(&output)).unwrap(), @r#"
[
{
"run_id": "[ULID]",
"status": {
"kind": "submitted"
},
"run_spec": {
"goal": {
"type": "inline",
"value": "Run tests and report results"
},
"workflow_name": "Simple",
"workflow_slug": "simple",
"sandbox_provider": null,
"dry_run": true,
"provenance": {
"server_version": "[VERSION]",
"client_name": "fabro-cli",
"client_version": "[VERSION]",
"subject_auth_method": "dev_token"
}
},
"start_record": null,
"conclusion": null,
"checkpoint": null,
"sandbox": {
"provider": "docker"
}
}
]
"#);
}
#[test]
fn inspect_completed_run_shows_run_start_conclusion_checkpoint() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let output = run_success(&context, &["inspect", &run.run_id]);
assert_snapshot!(serde_json::to_string_pretty(&compact_inspect(&output)).unwrap(), @r#"
[
{
"run_id": "[ULID]",
"status": {
"kind": "succeeded",
"reason": "completed"
},
"run_spec": {
"goal": {
"type": "inline",
"value": "Run tests and report results"
},
"workflow_name": "Simple",
"workflow_slug": "simple",
"sandbox_provider": null,
"dry_run": true,
"provenance": {
"server_version": "[VERSION]",
"client_name": "fabro-cli",
"client_version": "[VERSION]",
"subject_auth_method": "dev_token"
}
},
"start_record": {
"has_start_time": true
},
"conclusion": {
"status": "succeeded",
"timing": "[TIMING]",
"stage_count": 4
},
"checkpoint": {
"current_node": "exit",
"completed_nodes": null,
"next_node_id": null
},
"sandbox": {
"provider": "local"
}
}
]
"#);
}
#[test]
fn inspect_json_omits_run_dir() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let output = run_success(&context, &["inspect", &run.run_id]);
let items: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("inspect output should parse");
let first = items
.as_array()
.and_then(|items| items.first())
.expect("inspect output should contain one item");
assert!(
first.get("run_dir").is_none(),
"inspect JSON should not expose run_dir"
);
}
#[test]
fn inspect_completed_run_reads_store_without_disk_metadata_files() {
let context = test_context!();
let run = setup_seeded_completed_dry_run(&context);
let output = run_success(&context, &["inspect", &run.run_id]);
assert_snapshot!(serde_json::to_string_pretty(&compact_inspect(&output)).unwrap(), @r#"
[
{
"run_id": "[ULID]",
"status": {
"kind": "succeeded",
"reason": "completed"
},
"run_spec": {
"goal": {
"type": "inline",
"value": "Run tests and report results"
},
"workflow_name": "Simple",
"workflow_slug": "simple",
"sandbox_provider": null,
"dry_run": true,
"provenance": {
"server_version": "[VERSION]",
"client_name": "fabro-cli",
"client_version": "[VERSION]",
"subject_auth_method": "dev_token"
}
},
"start_record": {
"has_start_time": true
},
"conclusion": {
"status": "succeeded",
"timing": "[TIMING]",
"stage_count": 4
},
"checkpoint": {
"current_node": "exit",
"completed_nodes": null,
"next_node_id": null
},
"sandbox": {
"provider": "local"
}
}
]
"#);
}