runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! End-to-end test for the `runtime` binary.
//!
//! Spins up the in-process `runtime-api` HTTP server (same harness
//! shape as `crates/runtime-api/tests/auth_e2e.rs`), signs up a user,
//! creates a repository, mints a REPO_ADMIN PAT through the GraphQL
//! mutation, writes a `config.json` to a tempdir, then drives the
//! `runtime` binary via `std::process::Command` and asserts the
//! exit codes / stdout shapes.

use std::process::Command;
use std::sync::Arc;

use runtime_api::{http, schema, transport::TransportState};
use runtime_auth::{AllowAllAuthProvider, AuthProvider};
use runtime_domain::RuntimeRepository;
use runtime_events::EventBus;
use runtime_git::layout::StorageLayout;
use runtime_storage::MemoryReadModel;
use serde_json::{json, Value};
use tempfile::TempDir;
use tokio::net::TcpListener;

struct ApiHandle {
    port: u16,
    _server: tokio::task::JoinHandle<()>,
    _tmp: TempDir,
}

async fn spawn_api() -> ApiHandle {
    let tmp = TempDir::new().expect("tempdir");
    let layout = StorageLayout::new(tmp.path());
    let event_bus = EventBus::new(16);
    let auth: Arc<dyn AuthProvider> = Arc::new(AllowAllAuthProvider);
    let repo: Arc<dyn RuntimeRepository> = Arc::new(MemoryReadModel::new(layout.clone()));

    let schema = schema::build_schema(
        repo.clone(),
        event_bus.clone(),
        layout.clone(),
        auth.clone(),
        schema::default_clone_url_base(),
    );
    let transport_state = TransportState::new(layout, auth, event_bus, repo.clone());
    let app = http::router(
        schema,
        transport_state,
        repo.clone(),
        runtime_api::hook_bridge::HookBridgeState::disabled(repo.clone()),
        runtime_api::runner_bridge::RunnerBridgeState::disabled(repo.clone()),
    );

    let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
    let port = listener.local_addr().expect("local_addr").port();
    let server = tokio::spawn(async move {
        axum::serve(listener, app).await.expect("axum serve");
    });

    ApiHandle {
        port,
        _server: server,
        _tmp: tmp,
    }
}

async fn gql(port: u16, body: &Value, token: Option<&str>) -> Value {
    let client = reqwest::Client::new();
    let mut req = client
        .post(format!("http://127.0.0.1:{port}/graphql"))
        .json(body);
    if let Some(t) = token {
        req = req.bearer_auth(t);
    }
    let resp = req.send().await.expect("send");
    resp.json().await.expect("json")
}

/// Boot the API, provision a user + repo + PAT, drop a config file in
/// a temp dir, then drive the built `runtime` binary against it.
#[test]
fn cli_drives_live_api_end_to_end() {
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()
        .expect("rt");

    let (port, token, config_dir, _api) = rt.block_on(async {
        let api = spawn_api().await;
        let port = api.port;

        // Sign up.
        let signup = gql(
            port,
            &json!({
                "query": "mutation($i: SignUpInput!) { signUp(input: $i) { token account { id username } } }",
                "variables": {
                    "i": {
                        "username": "alice",
                        "displayName": "Alice",
                        "password": "hunter2hunter2",
                    }
                }
            }),
            None,
        )
        .await;
        let session = signup["data"]["signUp"]["token"]
            .as_str()
            .expect("signup token")
            .to_string();

        // Mint a REPO_ADMIN PAT.
        let pat = gql(
            port,
            &json!({
                "query": "mutation($i: CreatePatInput!) { createPersonalAccessToken(input: $i) { token } }",
                "variables": { "i": { "name": "cli-test", "scopes": ["REPO_READ", "REPO_WRITE", "REPO_ADMIN", "WORKFLOW_DISPATCH"] } }
            }),
            Some(&session),
        )
        .await;
        let token = pat["data"]["createPersonalAccessToken"]["token"]
            .as_str()
            .expect("pat token")
            .to_string();

        // Create a repository owned by alice.
        gql(
            port,
            &json!({
                "query": "mutation($i: CreateRepositoryInput!) { createRepository(input: $i) { id name } }",
                "variables": { "i": { "ownerSlug": "alice", "name": "r1", "visibility": "PUBLIC" } }
            }),
            Some(&token),
        )
        .await;

        // Write a config file the CLI can find via RUNTIME_CONFIG_HOME.
        let config_dir = TempDir::new().expect("cfgdir");
        let cfg = json!({
            "host": format!("http://127.0.0.1:{port}"),
            "username": "alice",
            "token": token,
        });
        std::fs::write(
            config_dir.path().join("config.json"),
            serde_json::to_vec_pretty(&cfg).unwrap(),
        )
        .expect("write config");

        (port, token, config_dir, api)
    });

    let bin = env!("CARGO_BIN_EXE_runtime");
    let host = format!("http://127.0.0.1:{port}");

    // --- auth status ---
    let out = Command::new(bin)
        .env("RUNTIME_CONFIG_HOME", config_dir.path())
        .args(["auth", "status"])
        .output()
        .expect("run auth status");
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(stdout.contains(&host), "auth status stdout: {stdout}");
    assert!(stdout.contains("alice"), "auth status stdout: {stdout}");

    // --- repo list ---
    let out = Command::new(bin)
        .env("RUNTIME_CONFIG_HOME", config_dir.path())
        .args(["repo", "list"])
        .output()
        .expect("run repo list");
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(stdout.contains("alice/r1"), "repo list stdout: {stdout}");

    // --- issue create then list ---
    let out = Command::new(bin)
        .env("RUNTIME_CONFIG_HOME", config_dir.path())
        .args([
            "issue", "create", "alice/r1", "--title", "Hello", "--body", "World",
        ])
        .output()
        .expect("run issue create");
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(stdout.contains("#1"), "issue create stdout: {stdout}");

    let out = Command::new(bin)
        .env("RUNTIME_CONFIG_HOME", config_dir.path())
        .args(["issue", "list", "alice/r1", "--json"])
        .output()
        .expect("run issue list");
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8(out.stdout).unwrap();
    let parsed: Value = serde_json::from_str(&stdout).expect("issue list json");
    let arr = parsed.as_array().expect("array");
    assert_eq!(arr.len(), 1);
    assert_eq!(arr[0]["title"], "Hello");
    assert_eq!(arr[0]["status"], "OPEN");

    // --- api escape hatch ---
    let out = Command::new(bin)
        .env("RUNTIME_CONFIG_HOME", config_dir.path())
        .args(["api", "{ viewer { username } }"])
        .output()
        .expect("run api");
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8(out.stdout).unwrap();
    let parsed: Value = serde_json::from_str(&stdout).expect("api json");
    assert_eq!(parsed["data"]["viewer"]["username"], "alice");

    // --- issue close ---
    let out = Command::new(bin)
        .env("RUNTIME_CONFIG_HOME", config_dir.path())
        .args(["issue", "close", "alice/r1", "1"])
        .output()
        .expect("run issue close");
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(stdout.contains("Closed"), "issue close stdout: {stdout}");

    // --- issue list --state=closed ---
    let out = Command::new(bin)
        .env("RUNTIME_CONFIG_HOME", config_dir.path())
        .args(["issue", "list", "alice/r1", "--state", "closed", "--json"])
        .output()
        .expect("run issue list closed");
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let parsed: Value = serde_json::from_str(&String::from_utf8(out.stdout).unwrap()).unwrap();
    assert_eq!(parsed.as_array().unwrap().len(), 1);

    // --- auth logout ---
    let out = Command::new(bin)
        .env("RUNTIME_CONFIG_HOME", config_dir.path())
        .args(["auth", "logout"])
        .output()
        .expect("run auth logout");
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // After logout, status must indicate not-logged-in.
    let out = Command::new(bin)
        .env("RUNTIME_CONFIG_HOME", config_dir.path())
        .args(["auth", "status"])
        .output()
        .expect("run auth status after logout");
    assert!(out.status.success());
    let stdout = String::from_utf8(out.stdout).unwrap();
    assert!(
        stdout.contains("Not logged in"),
        "post-logout status: {stdout}"
    );

    drop(token);
}