Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225//! What a failure looks like under `--json`, driven through the mock services.//!//! docs/output.md promises three things to a caller that parses: stdout holds//! the one answer or nothing, the status says which kind of failure, and the//! last stderr line is an object naming the same kind. Each case below holds//! all three for one way a command fails.
#![cfg(test)]
mod support;
use serde_json::json;use support::world::Refusal;use support::{ALICE, BOB, REPO_DID, Run, Scenario, TICKET_NSID};
/// A port on loopback with nothing behind it.const CLOSED: &str = "http://127.0.0.1:1";
/// The error object a failed `--json` run ended stderr with, after checking/// that stdout is empty and that the object agrees with the status.fn error_object(run: &Run, exit: i32, kind: &str) -> serde_json::Value { assert_eq!( run.code, Some(exit), "`atgc {}` exited {:?}\n--- stderr ---\n{}", run.args, run.code, run.stderr ); assert_eq!(run.stdout, "", "a failure printed to stdout"); let last = run.stderr.lines().last().unwrap_or_default(); let value: serde_json::Value = serde_json::from_str(last).unwrap_or_else(|e| { panic!( "`atgc {}` did not end stderr with JSON ({e})\n--- stderr ---\n{}", run.args, run.stderr ) }); let error = &value["error"]; assert_eq!(error["exit"], exit, "{value}"); assert_eq!(error["kind"], kind, "{value}"); assert!( error["message"].as_str().is_some_and(|m| !m.is_empty()), "{value}" ); assert!( !run.stderr.lines().any(|l| l.starts_with("error:")), "the text line was printed as well:\n{}", run.stderr ); error.clone()}
/// A ticket in the repo's account, as `ticket_state_flows` plants one.fn plant(world: &Scenario, number: u32, pull: bool, author: &str) { let mut value = json!({ "$type": TICKET_NSID, "title": { "$type": "org.tangled.markup.markdown#inline", "text": "a ticket" }, "body": { "$type": "org.tangled.markup.markdown", "text": "why" }, "state": "open", "createdAt": "2026-01-01T00:00:00Z", "x-tngl-editor": author, }); if pull { value["targetBranch"] = json!("main"); } world.with(|w| w.plant(REPO_DID, TICKET_NSID, &number.to_string(), value));}
/// A flag clap does not know is `usage` too, and its tip is the hint.////// Without `--json` the same mistake prints clap's own text, unchanged.#[test]fn a_bad_flag_is_a_usage_object() { let world = Scenario::new("json-error-usage");
let run = world.run(&["pr", "view", "1", "--json", "--bogus"]); let error = error_object(&run, 2, "usage"); assert_eq!(error["message"], "unexpected argument '--bogus' found"); assert!( error["hint"].as_str().is_some_and(|h| h.contains("Usage:")), "{error}" ); assert_eq!(error["xrpc_error"], serde_json::Value::Null);
let text = world.run(&["pr", "view", "1", "--bogus"]); assert_eq!(text.code, Some(2)); assert!( text.stderr.starts_with("error: unexpected argument"), "{}", text.stderr );}
/// `--fields` refuses as `usage` under `--json` like any other flag, and it/// never trims the error object, whose keys are not the ones it names.#[test]fn fields_leaves_the_error_object_whole() { let world = Scenario::new("json-error-fields");
let run = world.run(&["issue", "list", "--json", "--fields", "number..title"]); let error = error_object(&run, 2, "usage"); assert!( error["message"] .as_str() .is_some_and(|m| m.contains("is not a key path")), "{error}" );
let run = world.run(&["logs", "git", "--json", "--fields", "ts"]); let error = error_object(&run, 2, "usage"); assert!( error["message"] .as_str() .is_some_and(|m| m.contains("JSON Lines")), "{error}" );
let uri = format!("at://{REPO_DID}/{TICKET_NSID}/999"); let run = world.run(&["issue", "close", &uri, "--json", "--fields", "number"]); error_object(&run, 5, "not_found");}
/// A ticket the knot does not hold is `not_found`.#[test]fn a_missing_ticket_is_a_not_found_object() { let world = Scenario::new("json-error-not-found");
let uri = format!("at://{REPO_DID}/{TICKET_NSID}/999"); let run = world.run(&["issue", "close", &uri, "--json"]); error_object(&run, 5, "not_found");}
/// A spindle's refusal keeps its XRPC error name.#[test]fn a_spindle_refusal_names_its_xrpc_error() { let world = Scenario::new("json-error-spindle"); world.with_spindle(Vec::new()); world.with(|w| w.spindle_repo_unknown = true);
let run = world.run(&["run", "list", "--json"]); let error = error_object(&run, 5, "not_found"); assert_eq!(error["xrpc_error"], "RepoNotFound");}
/// A knot refusing somebody else's ticket is `denied`, with the knot's tag.#[test]fn a_refused_write_is_a_denied_object() { let world = Scenario::new("json-error-denied"); plant(&world, 1, true, ALICE);
let run = world.run_as(BOB, &["pr", "close", "1", "--json"]); let error = error_object(&run, 4, "denied"); assert!( error["message"] .as_str() .is_some_and(|m| m.contains("only the ticket's author or a moderator")), "{error}" ); assert_eq!(error["xrpc_error"], "Forbidden");}
/// A public PDS read keeps the service's own error name in `xrpc_error`.#[test]fn a_pds_read_refusal_names_its_xrpc_error() { let world = Scenario::new("json-error-pds-read"); world.with(|w| { w.refuses = Some(Refusal { service: "pds", method: "com.atproto.repo.listRecords", collection: None, status: 400, error: "InvalidRequest", }) });
let run = world.run(&["repo", "list", "--json"]); let error = error_object(&run, 1, "failure"); assert_eq!(error["xrpc_error"], "InvalidRequest");}
/// A public knot read keeps the service's own error name too.#[test]fn a_knot_read_refusal_names_its_xrpc_error() { let world = Scenario::new("json-error-knot-read"); world.with(|w| { w.refuses = Some(Refusal { service: "knot", method: "com.atproto.repo.listRecords", collection: None, status: 403, error: "Forbidden", }) });
let run = world.run(&["repo", "collaborators", REPO_DID, "--json"]); let error = error_object(&run, 4, "denied"); assert_eq!(error["xrpc_error"], "Forbidden");}
/// A resolver that does not answer is `unreachable`.#[test]fn an_unreachable_host_is_an_unreachable_object() { let world = Scenario::new("json-error-unreachable");
let uri = format!("at://{REPO_DID}/{TICKET_NSID}/1"); let run = world .command(&["issue", "close", &uri, "--json"]) .env("ATGC_PLC", CLOSED) .finish(); error_object(&run, 6, "unreachable");}
/// A success prints its one value on stdout and no error object anywhere.#[test]fn a_success_is_one_value_on_stdout_and_no_error() { let world = Scenario::new("json-error-success"); plant(&world, 1, false, ALICE);
let run = world .run_as(ALICE, &["issue", "close", "1", "--json"]) .success(); assert_eq!(run.json()["state_after"], "closed"); assert!(!run.stderr.contains("\"error\""), "{}", run.stderr);}