Files
plainleaf/bin/sb/tests/runtime_multi_e2e.rs
T

335 lines
12 KiB
Rust

//! End-to-end test for the Runtime API in **multi-space** mode, driven through
//! the real `sb` CLI.
//!
//! Two spaces are created on one authenticated server — one bound at the root
//! prefix, one at `/notes` — each holding a different marker page. The test then
//! evaluates Lua in each space through `sb eval` and asserts each answers with
//! its *own* content. That is the assertion that matters: a shared browser must
//! not collapse two spaces into one client.
//!
//! It also asserts the shared browser is genuinely shared, by requiring the
//! server to log its launch line exactly once across both spaces.
//!
//! Skips cleanly when Chrome is absent or when the sibling `silverbullet`
//! server binary hasn't been built (run `cargo test --workspace`, which builds
//! every bin) — except under `CI`, where either missing prerequisite is a hard
//! failure rather than a silent skip (see `common::chrome_available_or_skip`
//! and `server_bin_or_skip`).
use std::io::Read;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const ADMIN_USER: &str = "admin";
const ADMIN_PASSWORD: &str = "adminpw1";
#[path = "../../silverbullet/tests/common/mod.rs"]
mod common;
use common::{chrome_available_or_skip, free_port};
/// Wraps the spawned server and kills it (plus the shared Chrome it owns) on
/// drop, so nothing leaks even if an assertion panics.
///
/// stdout/stderr are drained continuously by background threads into `log`,
/// rather than read once at the end. Two headless clients (root + notes) each
/// forward their full `console.*` output into the server's log at INFO level
/// (see `ChromeConfig::log_console`), which is enough volume to fill the OS
/// pipe buffer within seconds; an unread `Stdio::piped()` handle would then
/// make the server block on its own log write, wedging the whole process —
/// including the HTTP listener — with no exception or panic to show for it.
struct Server {
child: Child,
log: Arc<Mutex<String>>,
readers: Vec<std::thread::JoinHandle<()>>,
}
/// Continuously copy `reader` into `log` until EOF/error (the process exited
/// and closed the pipe).
fn drain(mut reader: impl Read, log: Arc<Mutex<String>>) {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => return,
Ok(n) => log
.lock()
.unwrap_or_else(|e| e.into_inner())
.push_str(&String::from_utf8_lossy(&buf[..n])),
}
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Server {
/// Spawn `cmd` with piped stdout/stderr, immediately handing both to
/// dedicated reader threads so the child never blocks on a full pipe.
fn spawn(mut cmd: Command) -> Self {
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn silverbullet");
let log = Arc::new(Mutex::new(String::new()));
let mut readers = Vec::with_capacity(2);
if let Some(out) = child.stdout.take() {
let log = log.clone();
readers.push(std::thread::spawn(move || drain(out, log)));
}
if let Some(err) = child.stderr.take() {
let log = log.clone();
readers.push(std::thread::spawn(move || drain(err, log)));
}
Server {
child,
log,
readers,
}
}
/// Kill the server and return everything captured. Joining the reader
/// threads (rather than sleeping a guessed amount) blocks exactly until
/// each has drained its pipe to EOF, which follows promptly once `wait()`
/// confirms the process has exited and closed its ends.
fn finish(mut self) -> String {
let _ = self.child.kill();
let _ = self.child.wait();
for r in self.readers.drain(..) {
let _ = r.join();
}
self.log.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
fn sb_bin() -> &'static str {
env!("CARGO_BIN_EXE_sb")
}
/// The sibling `silverbullet` server binary in the same target dir, if built.
///
/// `None` means this test cannot run at all. Under `CI` that is a hard failure
/// rather than a silent skip, for the same reason as
/// `common::chrome_available_or_skip`: this job gates the release and docker
/// publishes, and a skipped run reads as a pass. It cannot trigger under the
/// current CI command (`cargo test --workspace --all-features` builds every bin
/// into the probed target dir), which is precisely why a silent skip here would
/// go unnoticed the day somebody narrows that command.
fn server_bin_or_skip() -> Option<PathBuf> {
let dir = PathBuf::from(sb_bin()).parent()?.to_path_buf();
let name = if cfg!(windows) {
"silverbullet.exe"
} else {
"silverbullet"
};
let p = dir.join(name);
if p.exists() {
return Some(p);
}
if std::env::var("CI").is_ok() {
panic!(
"runtime_multi_e2e: no `silverbullet` server binary at {} — this test drives the \
real server, so it cannot run without one. Refusing to skip in CI: run the suite as \
`cargo test --workspace --all-features`, which builds every bin into that directory.",
p.display()
);
}
eprintln!("skipping runtime_multi_e2e: silverbullet server binary not built");
None
}
/// Run `sb` with an isolated config home and capture stdout/stderr/exit code.
fn run_sb(args: &[&str], config_home: &std::path::Path) -> (i32, String, String) {
let out = Command::new(sb_bin())
.args(args)
.env("XDG_CONFIG_HOME", config_home)
.output()
.expect("spawn sb");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn runtime_api_serves_two_spaces_from_one_shared_chrome() {
if !chrome_available_or_skip("runtime_multi_e2e") {
return;
}
let Some(server) = server_bin_or_skip() else {
return;
};
let root = tempfile::tempdir().unwrap();
let sb_config = tempfile::tempdir().unwrap();
let chrome_data = root.path().join(".chrome-data");
// Provision the root through the real `setup` subcommand: admin account +
// spaces.json, no first space (both spaces are created over the admin API).
let out = Command::new(&server)
.arg("setup")
.arg(root.path())
.arg("--admin")
.arg(format!("{ADMIN_USER}:{ADMIN_PASSWORD}"))
.output()
.expect("spawn silverbullet setup");
assert!(
out.status.success(),
"setup failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let port = free_port();
let mut cmd = Command::new(&server);
cmd.arg(root.path())
.args(["-p", &port.to_string(), "-L", "127.0.0.1"])
.env_remove("SB_MULTI_SPACE")
.env_remove("SB_USER")
.env_remove("SB_RUNTIME_API")
.env("SB_DISABLE_SERVICE_WORKER", "1")
.env("SB_CHROME_DATA_DIR", &chrome_data);
let server_proc = Server::spawn(cmd);
let base = format!("http://127.0.0.1:{port}");
// Wait for the multi stack: the admin API answers 401 once it's mounted.
let probe = reqwest::blocking::Client::new();
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Ok(r) = probe.get(format!("{base}/.spaces/api/admin/spaces")).send() {
if r.status().as_u16() == 401 {
break;
}
}
assert!(Instant::now() < deadline, "server did not boot in time");
std::thread::sleep(Duration::from_millis(200));
}
// Log in as the admin.
let admin = reqwest::blocking::Client::builder()
.cookie_store(true)
.build()
.unwrap();
let r = admin
.post(format!("{base}/.spaces/api/login"))
.json(&serde_json::json!({ "username": ADMIN_USER, "password": ADMIN_PASSWORD }))
.send()
.unwrap();
assert!(r.status().is_success(), "admin login failed");
// Two spaces with the runtime API on. One is bound at the ROOT prefix, so
// its auth cookie is set with Path=/ and rides along on the other space's
// requests — exactly the case per-space cookie names exist to handle.
for (name, prefix, folder) in [
("Root", "/", "spaceRoot"),
("Notes", "/notes", "spaceNotes"),
] {
let r = admin
.post(format!("{base}/.spaces/api/admin/spaces"))
.json(&serde_json::json!({
"name": name,
"folder": folder,
"binding": { "prefix": prefix },
"runtimeApi": true
}))
.send()
.unwrap();
assert!(
r.status().is_success(),
"creating space {name} failed: {}",
r.text().unwrap_or_default()
);
}
// A distinguishing page per space, written before any runtime call so the
// headless client picks it up on its initial sync.
std::fs::write(
root.path().join("spaceRoot").join("Marker.md"),
"marker-from-root-space\n",
)
.unwrap();
std::fs::write(
root.path().join("spaceNotes").join("Marker.md"),
"marker-from-notes-space\n",
)
.unwrap();
// An admin API token for `sb --token`.
let r = admin
.post(format!(
"{base}/.spaces/api/admin/users/{ADMIN_USER}/tokens"
))
.json(&serde_json::json!({ "name": "e2e" }))
.send()
.unwrap();
assert!(r.status().is_success(), "token creation failed");
let token = r.json::<serde_json::Value>().unwrap()["token"]
.as_str()
.expect("token in response")
.to_string();
// Anonymous `sb` must be rejected.
let (code, _, stderr) = run_sb(&["--url", &base, "eval", "1 + 1"], sb_config.path());
assert_ne!(code, 0, "anonymous eval should fail");
assert!(
stderr.contains("401")
|| stderr.to_lowercase().contains("unauthor")
|| stderr.contains("authentication required"),
"expected an auth error, got: {stderr}"
);
// Each space answers with its own marker. The first call also pays for the
// browser launch and the client's first sync, so allow a generous window.
for (prefix, expected) in [
("", "marker-from-root-space"),
("/notes", "marker-from-notes-space"),
] {
let url = format!("{base}{prefix}");
// Must stay comfortably above the pool's LAUNCH_TIMEOUT (120s in
// server-runtime-chrome/src/pool.rs): the first space's eval pays for
// the browser launch plus a full client boot and sync.
let deadline = Instant::now() + Duration::from_secs(180);
#[allow(unused_assignments)]
let mut last = String::new();
loop {
let (code, stdout, stderr) = run_sb(
&[
"--url",
&url,
"--token",
&token,
"eval",
"space.readPage(\"Marker\")",
],
sb_config.path(),
);
if code == 0 && stdout.contains(expected) {
break;
}
last = format!("code={code} stdout={stdout:?} stderr={stderr:?}");
if Instant::now() >= deadline {
let log = server_proc.finish();
panic!(
"space at {url:?} never returned {expected:?}\nlast: {last}\n\
--- server log ---\n{log}"
);
}
std::thread::sleep(Duration::from_secs(1));
}
}
// One browser served both spaces.
let log = server_proc.finish();
let launches = log.matches("launching shared headless Chrome").count();
assert_eq!(
launches, 1,
"expected exactly one shared Chrome launch, saw {launches}\n--- server log ---\n{log}"
);
}