semora-cli 0.6.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
mod common;

use std::process::Stdio;
use std::time::Duration;

use common::*;
use semora_proto::{Level, Scope};
use semora_server::auth::{apikey, users};
use semora_server::projects::access::{self, ProjectAction};
use serde_json::json;

async fn push_doc(cli: &Cli) -> String {
    cli.write("plan.md", "# Plan\n\n## Overview\n\nSome text.");
    let out = cli.run(&["push", "plan.md", "--json"]).await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    out.json()["id"].as_str().unwrap().to_string()
}

fn comment(text: &str) -> serde_json::Value {
    json!({"type": "comment", "text": text,
           "anchor": {"path": "plan.md", "quote": "Some text", "prefix": "", "suffix": ".", "heading": "Overview"}})
}

/// Spawns the CLI in the background and returns a handle that yields its output.
fn spawn(cli: &Cli, args: &[&str]) -> tokio::task::JoinHandle<Out> {
    let mut cmd = cli.command(args);
    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
    tokio::task::spawn_blocking(move || {
        let o = cmd.output().unwrap();
        Out {
            code: o.status.code().unwrap_or(-1),
            stdout: String::from_utf8_lossy(&o.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
        }
    })
}

#[tokio::test(flavor = "multi_thread")]
async fn wait_collects_a_burst_prints_it_and_acks() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    let id = push_doc(&cli).await;
    let mut browser = server.browser(&id).await;

    let waiting = spawn(
        &cli,
        &["wait", &id, "--timeout", "30", "--collect", "1", "--json"],
    );
    browser.wait_for_agent().await;
    browser.send(comment("Reword this sentence.")).await;
    browser
        .send(json!({"type": "message", "text": "Also add a summary."}))
        .await;
    let out = waiting.await.unwrap();
    assert_eq!(out.code, 0, "{}", out.stderr);
    let v = out.json();
    assert_eq!(v["doc_id"], id.as_str());
    let events = v["events"].as_array().unwrap();
    assert_eq!(events.len(), 2, "{v}");
    assert_eq!(events[0]["kind"], "comment");
    assert_eq!(events[0]["payload"]["anchor"]["heading"], "Overview");
    assert_eq!(events[1]["payload"]["text"], "Also add a summary.");

    // Acked: the next wait sees nothing and times out with exit 2.
    let out = cli.run(&["wait", &id, "--timeout", "2"]).await;
    assert_eq!(out.code, 2, "{}", out.stderr);
}

#[tokio::test(flavor = "multi_thread")]
async fn wait_text_output_is_readable_for_agents() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    let id = push_doc(&cli).await;
    let mut browser = server.browser(&id).await;
    let waiting = spawn(&cli, &["wait", &id, "--timeout", "30", "--collect", "0.5"]);
    browser.wait_for_agent().await;
    browser.send(comment("Reword this sentence.")).await;
    let out = waiting.await.unwrap();
    assert_eq!(out.code, 0, "{}", out.stderr);
    for needle in [
        "comment",
        "plan.md",
        "Overview",
        "Some text",
        "Reword this sentence.",
        &format!("semora say {id}"),
    ] {
        assert!(
            out.stdout.contains(needle),
            "missing {needle:?} in:\n{}",
            out.stdout
        );
    }
}

#[tokio::test(flavor = "multi_thread")]
async fn feedback_sent_while_no_agent_waits_is_delivered_later() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    let id = push_doc(&cli).await;
    let mut browser = server.browser(&id).await;
    browser.next_of("hello", 5000).await;
    browser
        .send(json!({"type": "page_event", "name": "approve", "data": {"option": "B"}}))
        .await;
    tokio::time::sleep(Duration::from_millis(300)).await;
    let out = cli
        .run(&["wait", &id, "--timeout", "10", "--collect", "0.2", "--json"])
        .await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    assert_eq!(out.json()["events"][0]["payload"]["data"]["option"], "B");
}

#[tokio::test(flavor = "multi_thread")]
async fn wait_exit_codes() {
    let server = Server::start().await;
    let mut cli = Cli::new(&server);
    let id = push_doc(&cli).await;
    assert_eq!(
        cli.run(&["wait", &id, "--timeout", "1"]).await.code,
        2,
        "timeout"
    );
    assert_eq!(
        cli.run(&["wait", "AAAAAAAAAA", "--timeout", "1"])
            .await
            .code,
        4,
        "not found"
    );
    let ws_url = format!("{}/ws/d/{id}", server.url.replacen("http", "ws", 1));
    assert_eq!(
        cli.run(&["wait", &ws_url, "--timeout", "1"]).await.code,
        2,
        "websocket URLs work too"
    );
    cli.key = Some("sm_AAAAAAAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into());
    assert_eq!(
        cli.run(&["wait", &id, "--timeout", "1"]).await.code,
        3,
        "auth"
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn wait_downloads_screenshots() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    let id = push_doc(&cli).await;
    let mut browser = server.browser(&id).await;
    let png = b"\x89PNG\r\n\x1a\nscreenshot-bytes".to_vec();
    let resp = reqwest::Client::new()
        .post(format!("{}/api/v1/docs/{id}/attachments", server.url))
        .header("cookie", &browser.cookie)
        .header("origin", &server.url)
        .header("x-csrf-token", &browser.csrf)
        .header("content-type", "image/png")
        .body(png.clone())
        .send()
        .await
        .unwrap();
    assert!(resp.status().is_success());
    let attachment = resp.json::<serde_json::Value>().await.unwrap()["attachment_id"]
        .as_str()
        .unwrap()
        .to_string();

    let save = cli.path("shots");
    let save_s = save.to_string_lossy().into_owned();
    let waiting = spawn(
        &cli,
        &[
            "wait",
            &id,
            "--timeout",
            "30",
            "--collect",
            "0.3",
            "--json",
            "--save-dir",
            &save_s,
        ],
    );
    browser.wait_for_agent().await;
    browser
        .send(json!({"type": "drawing", "path": "plan.md", "note": "this box", "attachment_id": attachment,
                     "viewport": {"width": 800, "height": 600}, "scroll": {"x": 0, "y": 0}}))
        .await;
    let out = waiting.await.unwrap();
    assert_eq!(out.code, 0, "{}", out.stderr);
    let ev = &out.json()["events"][0];
    let path = ev["attachment_path"].as_str().expect("screenshot saved");
    assert_eq!(std::fs::read(path).unwrap(), png);
    assert!(path.starts_with(&save_s));
}

#[tokio::test(flavor = "multi_thread")]
async fn follow_streams_json_lines() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    let id = push_doc(&cli).await;
    let mut browser = server.browser(&id).await;
    let mut cmd = cli.command(&["wait", &id, "--follow"]);
    cmd.stdout(Stdio::piped());
    let mut child = cmd.spawn().unwrap();
    let stdout = child.stdout.take().unwrap();
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        use std::io::BufRead;
        for line in std::io::BufReader::new(stdout)
            .lines()
            .map_while(Result::ok)
        {
            let _ = tx.send(line);
        }
    });
    browser.wait_for_agent().await;
    browser.send(comment("one")).await;
    browser.send(comment("two")).await;
    let mut got = Vec::new();
    let deadline = std::time::Instant::now() + Duration::from_secs(15);
    while got.len() < 2 && std::time::Instant::now() < deadline {
        match rx.try_recv() {
            Ok(line) => got.push(serde_json::from_str::<serde_json::Value>(&line).unwrap()),
            Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
        }
    }
    child.kill().unwrap();
    let _ = child.wait();
    assert_eq!(got.len(), 2);
    assert_eq!(got[0]["payload"]["text"], "one");
    assert_eq!(got[1]["payload"]["text"], "two");
}

#[tokio::test(flavor = "multi_thread")]
async fn say_shows_a_message_in_the_page() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    let id = push_doc(&cli).await;
    let mut browser = server.browser(&id).await;
    browser.next_of("hello", 5000).await;
    let out = cli
        .run(&["say", &id, "On it — fixing the intro now."])
        .await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    let s = browser
        .next_of("say", 5000)
        .await
        .expect("page gets the message");
    assert_eq!(s["text"], "On it — fixing the intro now.");
}

#[tokio::test(flavor = "multi_thread")]
async fn push_with_wait_publishes_then_waits() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    cli.write("draft.md", "# Draft");
    let waiting = spawn(
        &cli,
        &[
            "push",
            "draft.md",
            "--wait",
            "--json",
            "--timeout",
            "30",
            "--collect",
            "0.3",
        ],
    );
    let deadline = std::time::Instant::now() + Duration::from_secs(15);
    while cli.cache().is_empty() && std::time::Instant::now() < deadline {
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    let id = cli.cache()[0][2].clone();
    let mut browser = server.browser(&id).await;
    browser.wait_for_agent().await;
    browser
        .send(json!({"type": "message", "text": "ship it"}))
        .await;
    let out = waiting.await.unwrap();
    assert_eq!(out.code, 0, "{}", out.stderr);
    assert_eq!(out.json()["events"][0]["payload"]["text"], "ship it");
}

#[tokio::test(flavor = "multi_thread")]
async fn wait_prints_who_sent_each_event() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    let id = push_doc(&cli).await;
    let mut browser = server.browser(&id).await;
    let waiting = spawn(&cli, &["wait", &id, "--timeout", "30", "--collect", "0.5"]);
    browser.wait_for_agent().await;
    browser.send(comment("Reword this sentence.")).await;
    browser
        .send(json!({"type": "message", "text": "Also add a summary."}))
        .await;
    let out = waiting.await.unwrap();
    assert_eq!(out.code, 0, "{}", out.stderr);
    assert_eq!(
        out.stdout.matches(" from Admin]").count(),
        2,
        "{}",
        out.stdout
    );
}

#[tokio::test(flavor = "multi_thread")]
async fn wait_json_carries_from() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    let id = push_doc(&cli).await;
    let mut browser = server.browser(&id).await;
    let waiting = spawn(
        &cli,
        &["wait", &id, "--timeout", "30", "--collect", "0.5", "--json"],
    );
    browser.wait_for_agent().await;
    browser.send(comment("Reword this sentence.")).await;
    let out = waiting.await.unwrap();
    let v = out.json();
    assert_eq!(v["events"][0]["from"]["name"], "Admin");
    assert_eq!(v["events"][0]["from"]["id"], server.user_id);
}

#[tokio::test(flavor = "multi_thread")]
async fn wait_as_a_reviewer_exits_3_with_the_level_message() {
    let server = Server::start().await;
    let cli = Cli::new(&server);
    // A project and a document in it, made with the owner's CLI.
    assert_eq!(
        cli.run(&["project", "init", "--name", "proj"]).await.code,
        0
    );
    cli.write("docs/plan.md", "# Plan");
    let out = cli.run(&["push", "docs/plan.md", "--json"]).await;
    assert_eq!(out.code, 0, "{}", out.stderr);
    let id = out.json()["id"].as_str().unwrap().to_string();
    // A second account, added as a Reviewer.
    let bo = users::create(
        &server.state.db,
        "bo@example.com",
        "Bo",
        "correct horse battery",
        false,
    )
    .await
    .unwrap();
    let (_, bo_key) = apikey::create(&server.state, bo.id, "bo", &Scope::all(), None, "web")
        .await
        .unwrap();
    let project = access::project_for(
        &server.state.db,
        server.user_id,
        "proj",
        ProjectAction::View,
    )
    .await
    .unwrap();
    semora_server::collab::add_collaborator(
        &server.state.db,
        project.id,
        bo.id,
        Level::Reviewer,
        Some(server.user_id),
    )
    .await
    .unwrap();
    let mut bo_cli = Cli::new(&server);
    bo_cli.key = Some(bo_key);
    let out = bo_cli.run(&["wait", &id, "--timeout", "5"]).await;
    assert_eq!(out.code, 3, "{}", out.stderr);
    assert!(
        out.stderr.contains("needs the Editor level"),
        "{}",
        out.stderr
    );
}