mod common;
use std::process::Stdio;
use std::time::Duration;
use common::*;
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"}})
}
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.");
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");
}