//! An integration environment: mock services, a logged-in account, a //! checkout, and a way to run whole *sequences* of real `atgc` commands //! against them. //! //! # Why this exists beside the unit tests //! //! docs/testing.md argues against mocking a service in order to test a parser, //! and that argument still holds: a fixture asserting what Bobbin returns //! passes precisely when our belief about Bobbin is self-consistent, which //! is the thing already known. //! //! This is the other direction, and it is not the same trade. What is //! asserted here is **what atgc sends, in what order, as whom** — the //! request side, which is entirely atgc's own behaviour and which no fixture //! can observe. `stack resubmit` writing a round onto the wrong pull record, //! a chain's `dependentOn` pointing at a record the same batch did not //! write, the second half of a stack going out under a different account's //! credentials: every one of those is a correct-looking call to a correct //! parser, and every one is invisible to a test of either half alone. //! //! The mocks are deliberately shallow about *responses* for the same reason //! the old argument gives — they answer with the shapes atgc already //! believes in, and they are not evidence about the real services. //! //! # Hermetic, still //! //! Nothing here opens a socket off the loopback interface, and every //! external name atgc has compiled in is redirected at a local port through //! [`crate::clients::endpoints`]'s environment overrides. `HOME` is moved //! too — which a unit test cannot do, because `std::env::set_var` is //! `unsafe` and this crate forbids unsafe code, but which a *child process* //! can, because `Command::env` is not. That is the whole reason these drive //! the built binary rather than calling functions. //! //! # What a test looks like //! //! ```ignore //! let world = Scenario::new("stack-create"); //! world.checkout.branch("feature"); //! world.checkout.commit("a.txt", "a\n", "feat: one", Some("Iaaa…")); //! world.run(&["stack", "create"]).success(); //! world.with(|w| assert_eq!(w.collection(ALICE, PULL_NSID).len(), 1)); //! ``` #![allow(dead_code)] pub mod account; pub mod appview; pub mod cid; pub mod git; pub mod http; pub mod invariants; pub mod services; pub mod world; use std::sync::{Arc, Mutex}; pub use account::Account; pub use world::World; /// The `sh.tangled.*` collections these tests assert against, spelled here /// rather than reached for out of the crate: an integration test naming the /// same constant the code writes with cannot notice the constant changing. pub const PULL_NSID: &str = "sh.tangled.repo.pull"; pub const PULL_STATUS_NSID: &str = "sh.tangled.repo.pull.status"; pub const ISSUE_NSID: &str = "sh.tangled.repo.issue"; pub const ISSUE_STATE_NSID: &str = "sh.tangled.repo.issue.state"; /// Where a comment goes — for an issue as much as for a pull. Spelled out /// here rather than reached for out of the crate precisely because it is the /// collection `issue comment` had a choice about: a test that named the same /// constant the code writes with could not notice the code changing its mind. pub const FEED_COMMENT_NSID: &str = "sh.tangled.feed.comment"; pub const REPO_NSID: &str = "sh.tangled.repo"; /// The accounts every scenario starts with. Two, always: a suite whose /// fixture holds one account cannot fail an identity test, because there is /// only one answer to give. pub const ALICE: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; pub const BOB: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; /// A third account, on a third PDS, who owns no repo in the default fixture /// and is logged in alongside the other two. /// /// Two accounts can only ever model "mine" and "not mine". Three can model /// the shape Tangled is actually used in: a contributor, a *second* /// contributor, and the account that owns the repo they are both aiming at — /// which is the case where "whose PDS holds this" stops being rhetorical. /// See [`Scenario::upstream`], where Carol owns the repo. pub const CAROL: &str = "did:plc:cacacacacacacacacacacaca"; /// A repo's own DID, which on Tangled is never its owner's — confusing the /// two is a real bug class, so the fixture keeps them visibly distinct. pub const REPO_DID: &str = "did:plc:rrrrrrrrrrrrrrrrrrrrrrrr"; /// The DID the mock knot mints for a repo `repo create` asks it to make. /// Distinct from [`REPO_DID`] so a test can tell a newly created repo from /// the one the scenario starts with. pub const NEW_REPO_DID: &str = "did:plc:cccccccccccccccccccccccc"; /// The knot the fixture repo record names. Contacted only through /// `ATGC_KNOT`, so the hostname itself never resolves. pub const KNOT: &str = "knot.test"; /// The document [`REPO_DID`] resolves to, in the shape a knot really mints: /// the endpoint is a URL *into* the knot, so the host is the only part of it /// anybody reads back out. pub fn repo_did_doc() -> serde_json::Value { serde_json::json!({ "id": REPO_DID, "alsoKnownAs": [], "service": [{ "id": "#tangled_knot", "type": "TangledKnot", "serviceEndpoint": format!("https://{KNOT}/repo/xd5wg4cdgxg7ezwlg42ezx5qeu"), }], }) } pub struct Scenario { world: Arc>, home: std::path::PathBuf, pub alice: Account, pub bob: Account, pub carol: Account, pub checkout: git::Checkout, pds: String, plc: String, bobbin: String, appview: String, knot: String, /// The home and the checkout both live under here, and it is deleted /// when the scenario drops. Last, so the checkout's own cleanup runs /// against a tree that is still there. _root: tempfile::TempDir, } impl Scenario { /// Stand up the mocks, three logged-in accounts each on its own PDS, and /// a checkout of a repo Alice owns. /// /// **Every account is a separate host.** A PDS answers for exactly one /// account and 404s for any other, because that is what a PDS does — so /// a command that asks the wrong one fails here the way it fails in /// production, rather than being quietly rescued by a shared endpoint. /// [`Scenario::upstream`] is the same fixture with the repo owned by a /// third account neither contributor is. /// /// `label` only names the temp directories, so a failed run's leftovers /// say which test made them. pub fn new(label: &str) -> Self { let world = Arc::new(Mutex::new(World::new())); let mut alice = Account::new(ALICE, "alice.test"); let mut bob = Account::new(BOB, "bob.test"); let mut carol = Account::new(CAROL, "carol.test"); for account in [&mut alice, &mut bob, &mut carol] { let did: &'static str = match account.did.as_str() { ALICE => ALICE, BOB => BOB, _ => CAROL, }; account.pds = http::serve(world.clone(), services::pds_for(did)) .expect("bind an account's mock PDS") .base; world .lock() .expect("the world") .pds_base .insert(did.to_string(), account.pds.clone()); } // Alice's, for the handful of assertions that want *a* PDS URL to // match against; every account's own is on the account. let pds = alice.pds.clone(); let plc = http::serve(world.clone(), services::plc) .expect("bind the mock PLC directory") .base; let bobbin = http::serve(world.clone(), services::bobbin) .expect("bind the mock Bobbin") .base; let appview = http::serve(world.clone(), services::appview) .expect("bind the mock appview") .base; let knot = http::serve(world.clone(), services::knot) .expect("bind the mock knot") .base; let root = tempfile::Builder::new() .prefix(&format!("atgc-scenario-{label}-")) .tempdir() .expect("a temp directory"); let home = root.path().join("home"); let config = home.join(".config/atgc"); account::install(&config, &[alice.clone(), bob.clone(), carol.clone()]); { let mut w = world.lock().expect("the world's lock"); w.dids.insert(alice.did.clone(), alice.did_doc()); w.dids.insert(bob.did.clone(), bob.did_doc()); w.dids.insert(carol.did.clone(), carol.did_doc()); // A repo's own document, which is a different shape from an // account's: no PDS, no handle, one service naming the knot that // minted it. It is how an account that does not own the repo // finds out where the repo lives, so a scenario without it would // make every non-owner path look broken. w.dids.insert(REPO_DID.to_string(), repo_did_doc()); w.tokens.insert(alice.token.clone(), alice.did.clone()); w.tokens.insert(bob.token.clone(), bob.did.clone()); w.tokens.insert(carol.token.clone(), carol.did.clone()); // The repo Alice owns. Its `sh.tangled.repo` record is what // `stack merge` reads to find the knot, and holding it is also // the ownership check that command makes. w.plant( ALICE, REPO_NSID, "demo", serde_json::json!({ "knot": KNOT, "owner": ALICE, "repoDid": REPO_DID, "name": "demo", "createdAt": "2026-01-01T00:00:00Z", }), ); // The same fact the appview publishes as a redirect from the // repo's DID. Planted beside the record rather than derived from // it, because the appview does not read the owner's PDS to // answer — it answers from its own index, and a command that // needs the owner has no other route to one. w.repo_owners.insert( REPO_DID.to_string(), (alice.did.clone(), "demo".to_string()), ); } let checkout = git::Checkout::new(root.path().join("checkout"), &knot, REPO_DID); Scenario { world, home, alice, bob, carol, checkout, pds, plc, bobbin, appview, knot, _root: root, } } /// Run one `atgc` command in the checkout, as Alice. pub fn run(&self, args: &[&str]) -> Run { self.run_as(&self.alice.did.clone(), args) } /// Run one `atgc` command as a named account, whatever the active /// pointer says. pub fn run_as(&self, did: &str, args: &[&str]) -> Run { self.command(args).env("ATGC_ACCOUNT", did).finish() } /// A command with nothing selecting an account, so the registry's own /// precedence chain decides — the active pointer, or the checkout's /// `user.email`, or a sole account. pub fn run_unselected(&self, args: &[&str]) -> Run { self.command(args).finish() } /// A directory of this scenario's own, made on demand and deleted with /// it. For the commands that are run somewhere *other* than a checkout — /// `repo create` starting from nothing has no checkout to run in, and /// making the one it is meant to make itself would be testing nothing. /// A path inside this scenario's `~/.config/atgc`, for a test that plants /// or reads one of the files atgc keeps there. /// The file one identity's account entry lives in. /// /// `dids///account.json`. There is no `accounts.json` any /// more: an entry is the identity's, and lives with its session and its /// keys. pub fn account_file(&self, did: &str) -> std::path::PathBuf { self.identity_dir(did).join("account.json") } /// One identity's directory. pub fn identity_dir(&self, did: &str) -> std::path::PathBuf { let (method, id) = did .strip_prefix("did:") .and_then(|r| r.split_once(':')) .expect("a two-part DID"); self.config_path("dids").join(method).join(id) } /// The file one identity's sessions live in. /// /// `dids///session.json`. Tests that used to read /// `sessions.json` read this, or [`Scenario::all_sessions`] when they are /// asking about the machine rather than an account. pub fn session_shard(&self, did: &str) -> std::path::PathBuf { self.identity_dir(did).join("session.json") } /// Every shard's text, concatenated — for a test asking whether some key /// exists anywhere rather than in one identity's file. pub fn all_sessions(&self) -> String { let mut out = String::new(); let dids = self.config_path("dids"); let Ok(methods) = std::fs::read_dir(&dids) else { return out; }; for method in methods.flatten() { let Ok(ids) = std::fs::read_dir(method.path()) else { continue; }; for id in ids.flatten() { if let Ok(text) = std::fs::read_to_string(id.path().join("session.json")) { out.push_str(&text); } } } out } /// Give an account an agent login as well as its person's one. /// /// The fixture writes a person's grant, which is what a registry written /// before planes looked like and what nearly every test wants. A test /// about agents needs the other half, and it has to name the *same* /// session the store holds — the session id is how a grant and a session /// find each other, so an agent grant pointing nowhere is an account that /// refuses rather than one that acts. /// Give the two planes' sessions different scope strings. /// /// The fixture records no scopes at all, which the pre-flight reads as /// "allowed to try", so nothing else here exercises the scope check. A /// plane-aware check can only be told from a plane-blind one when the two /// grants disagree, which is what this sets up. pub fn set_plane_scopes(&self, did: &str, handle: &str, human: &str, agent: &str) { let path = self.session_shard(did); let mut store: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).expect("a session store")).expect("json"); store[format!("oauth:{did}/test-session-{handle}")]["ClientSession"]["scope"] = serde_json::json!(human); store[format!("oauth:{did}/agent-session-{handle}")]["ClientSession"]["scope"] = serde_json::json!(agent); std::fs::write(&path, serde_json::to_vec_pretty(&store).expect("serialize")) .expect("write the store back"); } pub fn grant_agent_plane(&self, did: &str, handle: &str) { let path = self.account_file(did); let mut entry: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).expect("an entry")).expect("json"); let agent_session = format!("agent-session-{handle}"); entry["agent"] = serde_json::json!({ "client_id": "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A0%2Fcallback", "session_id": agent_session, }); std::fs::write(&path, serde_json::to_vec_pretty(&entry).expect("serialize")) .expect("write the registry back"); // And a session of its own in the store. Two grants mean two sessions; // pointing both planes at one entry would be a fixture that cannot // tell them apart, which is the very thing under test. let store_path = self.session_shard(did); let mut store: serde_json::Value = serde_json::from_slice(&std::fs::read(&store_path).expect("a session store")) .expect("json"); let mut session = store[format!("oauth:{did}/test-session-{handle}")].clone(); session["ClientSession"]["session_id"] = serde_json::json!(agent_session); // A distinct token, so a test can tell which grant answered. Two // planes that hand out the same string cannot catch one being read // for the other. session["ClientSession"]["access_token"] = serde_json::json!(format!("agent-token-for-{handle}")); // Later expiry too, so the plane-blind "freshest wins" lookup would // pick this one — which is what makes the leak reproducible rather // than dependent on map ordering. session["ClientSession"]["expires_at"] = serde_json::json!("2099-01-01T00:00:00Z"); store[format!("oauth:{did}/{agent_session}")] = session; std::fs::write( &store_path, serde_json::to_vec_pretty(&store).expect("serialize"), ) .expect("write the session store back"); } pub fn config_path(&self, name: &str) -> std::path::PathBuf { self.home.join(".config/atgc").join(name) } /// Every line of the OAuth log, parsed. /// /// This log is what answers "when did this credential stop being usable", /// so a test about credentials being given up has to be able to read it. /// Empty when nothing has written one yet, which is not an error: most /// commands write no OAuth events at all. pub fn oauth_log(&self) -> Vec { std::fs::read_to_string(self.config_path("oauth.jsonl")) .unwrap_or_default() .lines() .filter_map(|line| serde_json::from_str(line).ok()) .collect() } pub fn scratch(&self, name: &str) -> std::path::PathBuf { let dir = self._root.path().join(name); std::fs::create_dir_all(&dir).expect("a scratch directory"); dir } /// The builder behind [`Scenario::run`], for a command that needs one /// more environment variable than the shorthands set. pub fn command(&self, args: &[&str]) -> Invocation { let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_atgc")); command .args(args) .current_dir(&self.checkout.path) // Nothing may reach the real config directory or the real // services. Every one of these is a hard requirement, not a // tidiness: an unset override is a request to the production // host. .env("HOME", &self.home) // `HOME` alone stopped being enough when `config_dir` started // honouring `XDG_CONFIG_HOME`: a machine that exports one would // send every child here at the real session store, and the // suite would pass on this machine and read credentials on // somebody else's. .env_remove("XDG_CONFIG_HOME") // The plane this process is on is read from here at login, and // the suite is usually run from inside that harness — so leaving // it set makes every test an agent, and every fixture account is // a person's. Removed for the same reason as `XDG_CONFIG_HOME` // above: an inherited variable that changes what the tests mean. .env_remove("CLAUDECODE") // Same requirement, and the same failure if it is missed: a suite // run on a CI machine would inherit `CI` and meet refusals no // test here asks for. `auth login` is the first command to read // it on its own, and the one test that wants it sets it. .env_remove("CI") .env("ATGC_PLC", &self.plc) .env("ATGC_BOBBIN", &self.bobbin) .env("ATGC_APPVIEW", &self.appview) .env("ATGC_KNOT", &self.knot) .env("ATGC_BSKY_APPVIEW", &self.appview) .env("ATGC_NO_INPUT", "1") .env("NO_COLOR", "1") // git must not read the machine's own identity or hooks either. .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null") .env("GIT_AUTHOR_NAME", "atgc tests") .env("GIT_AUTHOR_EMAIL", "tests@example.invalid") .env("GIT_COMMITTER_NAME", "atgc tests") .env("GIT_COMMITTER_EMAIL", "tests@example.invalid"); Invocation { command, args: args.iter().map(|a| a.to_string()).collect(), } } /// Throw the OAuth sessions away, leaving the account registry as it is. /// /// This is what an expired grant looks like from atgc's side, and the two /// files are kept apart precisely so it can happen: `account::select` /// still resolves the account off `accounts.json`, and `agent_for_did` /// then finds nothing to resume. `auth::client_metadata` documents the /// bug that put every real session into this state about an hour after /// login, so it is the ordinary failure rather than an exotic one — and /// it is the only way to make a write fail for a reason that has nothing /// to do with the mocks agreeing with the client. pub fn forget_sessions(&self) { // Every identity's shard, since there is no longer one file to remove. let dids = self.config_path("dids"); let methods = std::fs::read_dir(&dids).expect("the dids directory"); for method in methods.flatten() { for id in std::fs::read_dir(method.path()) .expect("a method") .flatten() { let path = id.path().join("session.json"); if path.exists() { std::fs::remove_file(&path).expect("remove a session shard"); } } } } /// Read or mutate the mock world — plant a record, read what landed, /// inspect the journal. pub fn with(&self, f: impl FnOnce(&mut World) -> T) -> T { let mut world = self.world.lock().expect("the world's lock"); f(&mut world) } /// Forget every call taken so far, so the next command's journal stands /// alone. State is untouched: this is about the sequence, not the story. pub fn clear_journal(&self) { self.with(|w| w.journal.clear()); } /// Every pull record Alice holds, in record-key order — which for the /// TIDs `stack create` mints is bottom-of-the-stack first. pub fn pulls(&self, did: &str) -> Vec<(String, serde_json::Value)> { self.with(|w| { w.collection(did, PULL_NSID) .into_iter() .map(|(rkey, record)| (rkey, record.value)) .collect() }) } /// What Tangled's appview would make of the stack `did` holds at /// `rkey`. See [`appview`] for what is modelled and what that is worth. pub fn appview_stack(&self, did: &str, rkey: &str) -> appview::Stack { self.with(|w| appview::stack_of(w, did, rkey)) } /// Assert that atgc and the appview read the same stack off the same /// records. /// /// The invariant worth asserting after *any* stack write, and the one no /// other assertion in this suite can make. Everything else here checks /// that atgc sent what it meant to send; this checks that what it sent /// says the same thing to the other reader. A stack the two disagree /// about is a stack somebody is going to be shown wrongly, whatever /// `atgc stack view` says on the machine that wrote it. /// /// Entered through atgc's own bottom member, because a disagreement has /// to be found from where a person actually stands. pub fn assert_stack_reads_alike(&self, did: &str) { let json = self.run(&["stack", "view", "--json"]).success().json(); // Top first in `--json`, as everywhere else; the appview orders // bottom first. let mut mine: Vec = json["members"] .as_array() .expect("stack view --json has members") .iter() .map(|m| m["rkey"].as_str().unwrap_or_default().to_string()) .collect(); mine.reverse(); let bottom = mine.first().expect("a chain is never empty"); let theirs = self.appview_stack(did, bottom); assert_eq!( theirs, appview::Stack::Chain(mine.clone()), "atgc reads this stack as {mine:?}, the appview would not" ); } /// Every issue record an account holds, in record-key order. pub fn issues(&self, did: &str) -> Vec<(String, serde_json::Value)> { self.records(did, ISSUE_NSID) } /// Every record an account holds in one collection, in record-key order. pub fn records(&self, did: &str, nsid: &str) -> Vec<(String, serde_json::Value)> { self.with(|w| { w.collection(did, nsid) .into_iter() .map(|(rkey, record)| (rkey, record.value)) .collect() }) } /// The bytes the mock PDS holds under a CID. /// /// Panics when it holds none: a record naming a blob nobody uploaded is /// the failure these assertions exist to catch, and an absent-vs-absent /// comparison would pass on exactly that. pub fn blob(&self, cid: &str) -> Vec { self.with(|w| { w.blobs .get(cid) .unwrap_or_else(|| panic!("the PDS holds no blob {cid}")) .clone() }) } /// The same fixture with the repo owned by [`CAROL`], who is neither /// contributor. /// /// **The shape Tangled is actually used in.** Alice and Bob both have /// pull requests against a repository belonging to a third account, on a /// third PDS, and none of the three can read or write another's records. /// A fixture where the acting account owns the repo cannot tell "the /// owner's PDS" from "my PDS" — they are the same host and the same DID /// — so every rule that distinguishes them reads as correct whether it /// is or not. /// /// Carol holds the `sh.tangled.repo` record and the appview's redirect /// points at her, which is what a command has to follow to find the /// owner of a repo it is contributing to. pub fn upstream(label: &str) -> Self { let scenario = Scenario::new(label); scenario.with(|w| { let record = w .get(ALICE, REPO_NSID, "demo") .expect("the fixture repo record") .value .clone(); w.repo(ALICE) .records .remove(&(REPO_NSID.to_string(), "demo".to_string())); let mut record = record; record["owner"] = serde_json::json!(CAROL); w.plant(CAROL, REPO_NSID, "demo", record); w.repo_owners.insert( REPO_DID.to_string(), (CAROL.to_string(), "demo".to_string()), ); }); scenario } pub fn pds_url(&self) -> &str { &self.pds } /// The PLC directory's base, for the tests that assert a failure names /// the host it came from — a DID document is fetched from here, and the /// whole point of the size refusal is that it says which machine did it. pub fn plc_url(&self) -> &str { &self.plc } /// The appview's base, which is what every `url` field and `view:` line a /// command prints is built onto. /// /// Worth asserting against rather than ignoring: those strings used to be /// `https://tangled.org` spelled into a `format!`, so they named the /// production site no matter where the rest of the command was pointed, /// and a test could not tell a URL the code *built* from one it had /// hard-coded. Comparing against this is what makes that distinction /// visible. pub fn appview_url(&self) -> &str { &self.appview } /// How many rounds a pull record carries. A resubmit that appended /// nothing and a resubmit that appended twice are both bugs, and this is /// where either shows. pub fn rounds(&self, did: &str, rkey: &str) -> usize { self.with(|w| { w.get(did, PULL_NSID, rkey) .and_then(|r| r.value["rounds"].as_array().map(Vec::len)) .unwrap_or(0) }) } /// The patch text of one round of one pull, gunzipped out of the blob /// the record points at. /// /// This is the only way to see a stack's change-ids: they are not a /// field on the record anywhere, only a mail header in the patch, which /// is exactly why the correlation they drive is worth an integration /// test. pub fn round_patch(&self, did: &str, rkey: &str, round: usize) -> String { let gz = self.with(|w| { let record = w .get(did, PULL_NSID, rkey) .unwrap_or_else(|| panic!("no pull record {rkey} in {did}")); let rounds = record.value["rounds"] .as_array() .unwrap_or_else(|| panic!("{rkey} has no rounds: {:#}", record.value)); let entry = rounds .get(round) .unwrap_or_else(|| panic!("{rkey} has no round {round}: {:#}", record.value)); let cid = entry["patchBlob"]["ref"]["$link"] .as_str() .unwrap_or_else(|| panic!("round {round} of {rkey} has no blob: {entry:#}")) .to_string(); w.blobs .get(&cid) .unwrap_or_else(|| panic!("the PDS holds no blob {cid}")) .clone() }); ungzip(&gz) } /// The last round's patch — what a reconcile compares against. pub fn latest_patch(&self, did: &str, rkey: &str) -> String { let last = self.rounds(did, rkey).saturating_sub(1); self.round_patch(did, rkey, last) } /// The `Change-Id:` header of a pull's latest round: the identity that /// survives every rewrite of the branch, and the thing every reconcile /// matches on. pub fn change_id(&self, did: &str, rkey: &str) -> String { self.latest_patch(did, rkey) .lines() .find_map(|l| l.strip_prefix("Change-Id: ")) .unwrap_or("") .to_string() } } /// Gunzip, for reading a patch blob back out of the mock PDS. Every patch on /// the wire is gzipped, so nothing can be read without this. pub fn ungzip(bytes: &[u8]) -> String { use std::io::Read; let mut out = String::new(); flate2::read::GzDecoder::new(bytes) .read_to_string(&mut out) .expect("a patch blob is gzip"); out } /// A command with its environment set, not yet run. pub struct Invocation { command: std::process::Command, args: Vec, } impl Invocation { pub fn env(mut self, key: &str, value: &str) -> Self { self.command.env(key, value); self } /// Run somewhere other than the scenario's checkout. pub fn in_dir(mut self, dir: &std::path::Path) -> Self { self.command.current_dir(dir); self } /// Start the command and hand back the child, for a command that does /// not exit on its own. /// /// `auth login` prints an authorization URL and then waits for the /// callback, so nothing that waits for it to exit can drive it. The /// caller reads the URL off stdout, delivers the callback, and then /// waits. stdin is closed rather than inherited: a login with nobody at /// it is the case this exists to test. pub fn start(mut self) -> Waiting { self.command .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); Waiting { args: self.args.join(" "), child: self.command.spawn().expect("spawn atgc"), } } pub fn finish(mut self) -> Run { let output = self.command.output().expect("spawn atgc"); Run { args: self.args.join(" "), code: output.status.code(), stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned(), } } } /// A command still running, and the one line it has printed so far. pub struct Waiting { args: String, child: std::process::Child, } impl Waiting { /// The first line of stdout, blocking until it arrives. /// /// `auth login` flushes the authorization URL before it starts waiting, /// precisely so that whatever is driving it can read one line and act. pub fn first_line(&mut self) -> String { use std::io::{BufRead, BufReader}; let out = self.child.stdout.as_mut().expect("stdout is piped"); let mut line = String::new(); BufReader::new(out) .read_line(&mut line) .expect("read the first line"); line.trim().to_string() } /// Stop it, and hand back what it printed. /// /// For a test whose subject is that the login *started* — that it got /// past a refusal and reached the authorization server. Waiting for it to /// exit would mean waiting out the whole callback timeout for a callback /// the test was never going to deliver. pub fn kill(mut self) -> Run { let _ = self.child.kill(); self.finish() } /// Wait for it to finish. pub fn finish(self) -> Run { // Not `stdout.take()` first: taking the pipe drops it, and // `wait_with_output` then collects nothing. Every caller that only // checked an exit status passed anyway, which is how it survived. let output = self.child.wait_with_output().expect("wait for atgc"); Run { args: self.args, code: output.status.code(), stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned(), } } } /// What one command did. pub struct Run { pub args: String, pub code: Option, pub stdout: String, pub stderr: String, } impl Run { /// Assert it exited 0, and hand back the run so a test can go on to read /// its output. The panic carries both streams, because a failure here is /// usually explained by a line on stderr. pub fn success(self) -> Self { assert_eq!( self.code, Some(0), "`atgc {}` failed\n--- stdout ---\n{}\n--- stderr ---\n{}", self.args, self.stdout, self.stderr, ); self } /// Assert it failed, and that its message says `needle`. pub fn refused(self, needle: &str) -> Self { assert_ne!( self.code, Some(0), "`atgc {}` was expected to fail\n--- stdout ---\n{}", self.args, self.stdout, ); assert!( self.stderr.contains(needle), "`atgc {}` did not explain itself with {needle:?}\n--- stderr ---\n{}", self.args, self.stderr, ); self } /// The same, and the exit status it carried. /// /// Worth a second assertion because the status is half the contract: /// docs/output.md makes the numbers a public interface, and a refusal /// that exits `1` says only that something went wrong. [`Run::refused`] /// passes against every one of them, so a call site that loses its /// classification is invisible to it. pub fn refused_with(self, exit: i32, needle: &str) -> Self { let refused = self.refused(needle); assert_eq!( refused.code, Some(exit), "`atgc {}` exited {:?}, not {exit}\n--- stderr ---\n{}", refused.args, refused.code, refused.stderr, ); refused } /// stdout parsed as the one JSON object a `--json` command prints. pub fn json(&self) -> serde_json::Value { serde_json::from_str(&self.stdout).unwrap_or_else(|e| { panic!( "`atgc {}` did not print JSON ({e})\n--- stdout ---\n{}\n--- stderr ---\n{}", self.args, self.stdout, self.stderr ) }) } }