|
| 1 | +//! Offline coverage of the CLI's surface. |
| 2 | +//! |
| 3 | +//! Nothing here touches the network or needs credentials — every path asserted |
| 4 | +//! below fails (or answers) before a socket is opened — so these run on any |
| 5 | +//! machine, in CI, for free. Contrast `live_chatgpt.rs`, which is opt-in. |
| 6 | +
|
| 7 | +use std::process::{Command, Stdio}; |
| 8 | + |
| 9 | +/// The one sentence a signed-out operator should ever see. Duplicated from |
| 10 | +/// `crate::auth::flow::NOT_SIGNED_IN` on purpose: an integration test cannot |
| 11 | +/// import from a binary-only crate, and a test that reads the constant it is |
| 12 | +/// checking would pass no matter what the constant said. |
| 13 | +/// |
| 14 | +/// The dash is U+2014. |
| 15 | +const NOT_SIGNED_IN: &str = "not signed in — run: llm-stream --login"; |
| 16 | + |
| 17 | +/// Runs the CLI against an empty, throwaway config directory, so there are no |
| 18 | +/// credentials to find. Returns `(exit_ok, stdout, stderr)`. |
| 19 | +fn run_without_credentials(args: &[&str]) -> (bool, String, String) { |
| 20 | + let dir = match tempfile::tempdir() { |
| 21 | + Ok(dir) => dir, |
| 22 | + Err(e) => panic!("could not create a temporary config directory: {e}"), |
| 23 | + }; |
| 24 | + let mut command = Command::new(env!("CARGO_BIN_EXE_llm-stream")); |
| 25 | + command.arg("--config-dir").arg(dir.path()); |
| 26 | + command.args(args); |
| 27 | + // Without this the child inherits the harness's stdin, and `parse_args` |
| 28 | + // reads stdin whenever it is not a terminal — the test would hang. |
| 29 | + let output = match command.stdin(Stdio::null()).output() { |
| 30 | + Ok(output) => output, |
| 31 | + Err(e) => panic!("could not run the llm-stream binary: {e}"), |
| 32 | + }; |
| 33 | + ( |
| 34 | + output.status.success(), |
| 35 | + String::from_utf8_lossy(&output.stdout).into_owned(), |
| 36 | + String::from_utf8_lossy(&output.stderr).into_owned(), |
| 37 | + ) |
| 38 | +} |
| 39 | + |
| 40 | +#[test] |
| 41 | +fn a_prompt_without_credentials_names_the_login_command() { |
| 42 | + let (ok, stdout, stderr) = run_without_credentials(&["--api", "chatgpt", "hi"]); |
| 43 | + assert!( |
| 44 | + !ok, |
| 45 | + "expected a failure exit\nstdout: {stdout}\nstderr: {stderr}" |
| 46 | + ); |
| 47 | + assert!(stderr.contains(NOT_SIGNED_IN), "stderr: {stderr}"); |
| 48 | + // A caller piping stdout must get an empty pipe, not half an error. |
| 49 | + assert!(stdout.is_empty(), "nothing should reach stdout: {stdout}"); |
| 50 | +} |
| 51 | + |
| 52 | +#[test] |
| 53 | +fn models_without_credentials_names_the_login_command() { |
| 54 | + let (ok, stdout, stderr) = run_without_credentials(&["--models"]); |
| 55 | + assert!( |
| 56 | + !ok, |
| 57 | + "expected a failure exit\nstdout: {stdout}\nstderr: {stderr}" |
| 58 | + ); |
| 59 | + assert!(stderr.contains(NOT_SIGNED_IN), "stderr: {stderr}"); |
| 60 | + // The quota warning must come after the credential check. Warning someone |
| 61 | + // about a cost they were never going to pay is noise that trains them to |
| 62 | + // ignore the warning when it matters. |
| 63 | + assert!( |
| 64 | + !stderr.contains("quota"), |
| 65 | + "no quota warning before the guard: {stderr}" |
| 66 | + ); |
| 67 | + assert!(stdout.is_empty(), "nothing should reach stdout: {stdout}"); |
| 68 | +} |
0 commit comments