mod common;
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use std::sync::mpsc;
use std::time::Duration;
use common::*;
fn spawn_watch(cli: &Cli, args: &[&str]) -> (std::process::Child, mpsc::Receiver<String>) {
spawn_watch_then(cli, args, || {})
}
fn spawn_watch_then(
cli: &Cli,
args: &[&str],
on_first_line: impl FnOnce() + Send + 'static,
) -> (std::process::Child, mpsc::Receiver<String>) {
let mut cmd = cli.command(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::inherit());
let mut child = cmd.spawn().unwrap();
let stdout = child.stdout.take().unwrap();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut hook = Some(on_first_line);
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
if let Some(run) = hook.take() {
run();
}
if tx.send(line).is_err() {
break;
}
}
});
(child, rx)
}
struct Running(std::process::Child);
impl Drop for Running {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
async fn next_push(rx: &mpsc::Receiver<String>, secs: u64) -> Option<serde_json::Value> {
let deadline = std::time::Instant::now() + Duration::from_secs(secs);
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
return None;
}
match rx.try_recv() {
Ok(line) => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line)
&& v.get("rev").is_some()
{
return Some(v);
}
}
Err(mpsc::TryRecvError::Empty) => tokio::time::sleep(Duration::from_millis(50)).await,
Err(mpsc::TryRecvError::Disconnected) => return None,
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn watch_repushes_a_folder_once_per_burst() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("site/index.md", "# One");
cli.write("site/other.md", "# Other");
let (child, rx) = spawn_watch(&cli, &["watch", "site", "--json"]);
let _child = Running(child);
let first = next_push(&rx, 15).await.expect("initial push");
assert_eq!(first["rev"], 1);
assert!(
next_push(&rx, 2).await.is_none(),
"no push without a change"
);
for n in 0..4 {
cli.write("site/index.md", format!("# Edit {n}"));
tokio::time::sleep(Duration::from_millis(80)).await;
}
cli.write("site/other.md", "# Other changed");
let second = next_push(&rx, 15).await.expect("push after edits");
assert_eq!(second["rev"], 2);
assert_eq!(second["id"], first["id"]);
assert!(
next_push(&rx, 2).await.is_none(),
"the burst was coalesced into one push"
);
cli.write("site/.notes", "scratch");
assert!(next_push(&rx, 2).await.is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn watch_a_single_file() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("doc.md", "# Start");
cli.write("unrelated.md", "# Unrelated");
let (child, rx) = spawn_watch(&cli, &["watch", "doc.md", "--json"]);
let _child = Running(child);
let first = next_push(&rx, 15).await.expect("initial push");
cli.write("unrelated.md", "# Changed but not watched");
assert!(
next_push(&rx, 2).await.is_none(),
"other files in the folder do not count"
);
cli.write("doc.md", "# Changed");
let second = next_push(&rx, 15).await.expect("push after edit");
assert_eq!(second["id"], first["id"]);
assert_eq!(second["rev"], 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn watch_takes_the_new_stored_key_after_a_login_revoked_its_own() {
use semora_proto::Scope;
use semora_server::auth::apikey;
let server = Server::start().await;
let mut cli = Cli::new(&server);
cli.key = None;
let store = semora_cli::credentials::Store::new(cli.config.path(), false);
store.set(&server.url, &server.key, "cli-test").unwrap();
cli.write("doc.md", "# One");
let (child, rx) = spawn_watch(&cli, &["watch", "doc.md", "--json"]);
let _child = Running(child);
let first = next_push(&rx, 15).await.expect("initial push");
let (_, fresh) = apikey::create(
&server.state,
server.user_id,
"again",
&Scope::all(),
None,
"web",
)
.await
.unwrap();
store.set(&server.url, &fresh, "again").unwrap();
let (old, _) = apikey::authenticate(&server.state.db, &server.key)
.await
.unwrap()
.expect("the first key");
assert!(
apikey::revoke(&server.state.db, None, old.id)
.await
.unwrap()
);
cli.write("doc.md", "# Two");
let second = next_push(&rx, 15)
.await
.expect("the push after the login uses the key that is stored now");
assert_eq!(second["id"], first["id"]);
assert_eq!(second["rev"], 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn watch_in_a_project_pushes_by_key() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# One");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let (child, rx) = spawn_watch(&cli, &["watch", "docs/plan.md", "--json"]);
let _child = Running(child);
let first = next_push(&rx, 15).await.expect("initial push");
assert_eq!(first["project"], "semora");
assert_eq!(first["rev"], 1);
let gone = cli.run(&["rm", first["id"].as_str().unwrap()]).await;
assert_eq!(gone.code, 0, "{}", gone.stderr);
cli.write("docs/plan.md", "# Two");
let second = next_push(&rx, 15).await.expect("push after the edit");
assert_ne!(
second["id"], first["id"],
"pushed by key, so a deleted document is made again"
);
assert_eq!(second["rev"], 1);
assert_eq!(second["path"], "docs/plan.md");
cli.write("docs/plan.md", "# Three");
let third = next_push(&rx, 15).await.expect("another push");
assert_eq!(third["id"], second["id"]);
assert_eq!(third["rev"], 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn watch_in_a_project_pushes_an_edit_made_the_instant_the_first_push_prints() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# One");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let path = cli.path("docs/plan.md");
let (child, rx) = spawn_watch_then(&cli, &["watch", "docs/plan.md", "--json"], move || {
std::fs::write(path, "# Two").unwrap()
});
let _child = Running(child);
let first = next_push(&rx, 15).await.expect("initial push");
assert_eq!(first["project"], "semora");
assert_eq!(first["rev"], 1);
let second = next_push(&rx, 15).await.expect("push after the edit");
assert_eq!(second["id"], first["id"]);
assert_eq!(second["rev"], 2);
assert_eq!(second["unchanged"], false);
}
#[tokio::test(flavor = "multi_thread")]
async fn watch_with_id_in_a_project_keeps_pushing_to_that_document() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# One");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let standalone = cli
.run(&["push", "docs/plan.md", "--new", "--json"])
.await
.json();
let id = standalone["id"].as_str().unwrap().to_string();
let path = cli.path("docs/plan.md");
let (child, rx) = spawn_watch_then(
&cli,
&["watch", "docs/plan.md", "--id", &id, "--json"],
move || std::fs::write(path, "# Two").unwrap(),
);
let child = Running(child);
let first = next_push(&rx, 15).await.expect("initial push");
assert_eq!(first["id"], id.as_str());
assert_eq!(
first["rev"], 1,
"the same bytes as the push --new: the current revision, no new one"
);
assert_eq!(first["unchanged"], true);
let second = next_push(&rx, 15).await.expect("push after the edit");
assert_eq!(
second["id"],
id.as_str(),
"still X, not the document at the key"
);
assert_eq!(second["rev"], 2);
assert_eq!(second["unchanged"], false);
drop(child);
let projects = cli.run(&["project", "ls", "--json"]).await.json();
assert_eq!(
projects[0]["documents"], 0,
"no document was made at the key"
);
}