//! 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); }