mod common;
use common::Sandbox;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
const BIN: &str = env!("CARGO_BIN_EXE_vivac");
fn port_of(url: &str) -> u16 {
let authority = url.trim_start_matches("http://");
let authority = authority.split('/').next().unwrap_or(authority);
authority
.rsplit(':')
.next()
.and_then(|p| p.parse().ok())
.unwrap_or_else(|| panic!("no port in the boot url: {url}"))
}
struct Server {
child: Child,
port: u16,
boot_url: String,
stdout: BufReader<std::process::ChildStdout>,
}
impl Server {
fn start(sandbox: &Sandbox) -> Server {
Server::start_serving(&sandbox.0, sandbox.global_home(), &[])
}
fn start_serving(
dir: &std::path::Path,
home: &std::path::Path,
roots: &[&std::path::Path],
) -> Server {
let mut args: Vec<std::ffi::OsString> = vec![
"web".into(),
"--port".into(),
"0".into(),
"--no-open".into(),
];
for r in roots {
args.push("--project".into());
args.push((*r).into());
}
let mut child = Command::new(BIN)
.current_dir(dir)
.env("VIVAC_HOME", home)
.env("TZ", "UTC")
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
let mut boot_url = None;
for _ in 0..10 {
let mut line = String::new();
let n = reader.read_line(&mut line).unwrap();
assert!(n > 0, "the server exited before printing a boot url");
if let Some(at) = line.find("http://") {
if line.contains("?k=") {
boot_url = Some(line[at..].trim().to_string());
break;
}
}
}
let boot_url = boot_url.expect("no boot url in the server's startup lines");
Server {
child,
port: port_of(&boot_url),
boot_url,
stdout: reader,
}
}
fn boot_path(&self) -> String {
let after_scheme = self.boot_url.trim_start_matches("http://");
let slash = after_scheme
.find('/')
.expect("the boot url has no path at all");
after_scheme[slash..].to_string()
}
fn host(&self) -> String {
format!("127.0.0.1:{}", self.port)
}
fn next_startup_line(&mut self) -> String {
let mut line = String::new();
self.stdout.read_line(&mut line).unwrap();
line.trim().to_string()
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
struct Up {
server: Server,
_sandbox: Sandbox,
}
impl Up {
fn port(&self) -> u16 {
self.server.port
}
fn boot_path(&self) -> String {
self.server.boot_path()
}
fn host(&self) -> String {
self.server.host()
}
}
struct Answer {
status: u16,
headers: Vec<(String, String)>,
body: String,
}
impl Answer {
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
fn read_body(reader: &mut impl BufRead, content_length: usize, chunked: bool) -> Vec<u8> {
if !chunked {
let mut body = vec![0u8; content_length];
reader.read_exact(&mut body).unwrap();
return body;
}
let mut body = Vec::new();
loop {
let mut header = String::new();
reader.read_line(&mut header).unwrap();
let size = usize::from_str_radix(
header
.trim_end_matches(['\r', '\n'])
.split(';')
.next()
.unwrap_or("")
.trim(),
16,
)
.unwrap_or_else(|_| panic!("not a chunk size: {header:?}"));
if size == 0 {
break;
}
let mut chunk = vec![0u8; size];
reader.read_exact(&mut chunk).unwrap();
body.extend_from_slice(&chunk);
let mut end = [0u8; 2];
reader.read_exact(&mut end).unwrap();
}
body
}
fn call(port: u16, path: &str, headers: &[(&str, String)]) -> Answer {
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = format!("GET {path} HTTP/1.1\r\n");
for (k, v) in headers {
request.push_str(&format!("{k}: {v}\r\n"));
}
request.push_str("Connection: close\r\n\r\n");
stream.write_all(request.as_bytes()).unwrap();
let mut reader = BufReader::new(stream);
let mut status_line = String::new();
reader.read_line(&mut status_line).unwrap();
let status: u16 = status_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| panic!("not a status line: {status_line:?}"));
let mut headers_out = Vec::new();
let mut content_length = 0usize;
let mut chunked = false;
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
break;
}
let (k, v) = trimmed
.split_once(':')
.unwrap_or_else(|| panic!("not a header line: {trimmed:?}"));
let (k, v) = (k.trim().to_string(), v.trim().to_string());
if k.eq_ignore_ascii_case("content-length") {
content_length = v.parse().unwrap_or(0);
}
if k.eq_ignore_ascii_case("transfer-encoding") && v.eq_ignore_ascii_case("chunked") {
chunked = true;
}
headers_out.push((k, v));
}
let body = read_body(&mut reader, content_length, chunked);
Answer {
status,
headers: headers_out,
body: String::from_utf8_lossy(&body).into_owned(),
}
}
fn token_from(a: &Answer) -> String {
let raw = a.header("Set-Cookie").unwrap_or_else(|| {
panic!(
"no Set-Cookie on the boot answer:
{}",
a.body
)
});
let after = raw
.split(';')
.next()
.and_then(|pair| pair.split_once('='))
.unwrap_or_else(|| panic!("no name=value in {raw}"));
assert_eq!(after.0.trim(), "vivac_session", "in {raw}");
after.1.trim().to_string()
}
fn hrefs_in(body: &str) -> Vec<String> {
let marker = "href=\"";
let mut out = Vec::new();
let mut rest = body;
while let Some(start) = rest.find(marker) {
let after = &rest[start + marker.len()..];
let Some(end) = after.find('"') else {
break;
};
if !after[..end].starts_with("data:") {
out.push(after[..end].to_string());
}
rest = &after[end + 1..];
}
out
}
fn up(name: &str) -> Up {
let sandbox = Sandbox::new_seeded(name);
let server = Server::start(&sandbox);
Up {
server,
_sandbox: sandbox,
}
}
struct UpMany {
server: Server,
_sandboxes: Vec<Sandbox>,
}
impl UpMany {
fn port(&self) -> u16 {
self.server.port
}
fn boot_path(&self) -> String {
self.server.boot_path()
}
fn host(&self) -> String {
self.server.host()
}
}
fn up_many(names: &[&str]) -> UpMany {
let sandboxes: Vec<Sandbox> = names.iter().map(|n| Sandbox::new_seeded(n)).collect();
let roots: Vec<&std::path::Path> = sandboxes.iter().map(|s| s.0.as_path()).collect();
let server = Server::start_serving(&std::env::temp_dir(), sandboxes[0].global_home(), &roots);
UpMany {
server,
_sandboxes: sandboxes,
}
}
#[test]
fn the_boot_key_hands_over_a_session_cookie_and_redirects() {
let s = up("boot");
let a = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
assert_eq!(a.status, 302, "{}", a.body);
let id = first_event_id(&s._sandbox.0);
assert_eq!(
a.header("Location"),
Some(format!("/p/{id}/")).as_deref(),
"{}",
a.body
);
assert_eq!(token_from(&a).len(), 64, "{}", a.body);
let jar = a.header("Set-Cookie").unwrap();
assert!(jar.contains("HttpOnly"), "{jar}");
assert!(jar.contains("SameSite=Strict"), "{jar}");
assert!(jar.contains("Path=/"), "{jar}");
assert!(!jar.contains("Max-Age"), "{jar}");
assert!(!jar.contains("Expires"), "{jar}");
}
#[test]
fn the_same_boot_url_a_second_time_is_refused() {
let s = up("boot-twice");
let path = s.boot_path();
let first = call(s.port(), &path, &[("Host", s.host())]);
assert_eq!(first.status, 302, "{}", first.body);
let second = call(s.port(), &path, &[("Host", s.host())]);
assert_eq!(
second.status, 401,
"a spent boot key still unlocked something:\n{}",
second.body
);
}
#[test]
fn a_good_token_in_the_header_serves() {
let s = up("good-token");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let a = call(
s.port(),
"/",
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
}
#[test]
fn a_single_project_index_lists_it_rather_than_redirecting() {
let s = up("index-one");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let a = call(
s.port(),
"/",
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
let id = first_event_id(&s._sandbox.0);
assert!(
a.body.contains(&format!("/p/{id}/")),
"the index does not link to the only project:\n{}",
a.body
);
}
#[test]
fn the_index_stays_reachable_after_the_boot_lands_on_a_project() {
let s = up("index-reachable");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let id = first_event_id(&s._sandbox.0);
assert_eq!(boot.status, 302, "{}", boot.body);
assert_eq!(
boot.header("Location"),
Some(format!("/p/{id}/")).as_deref(),
"{:?}",
boot.headers
);
let token = token_from(&boot);
let index = call(
s.port(),
"/",
&[("Host", s.host()), ("X-Vivac-Token", token.clone())],
);
assert_eq!(index.status, 200, "{}", index.body);
assert!(index.body.contains(&format!("/p/{id}/")), "{}", index.body);
let today = call(
s.port(),
&format!("/p/{id}/"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(today.status, 200, "{}", today.body);
assert!(
today.body.contains("href=\"/\""),
"no way back to the index:\n{}",
today.body
);
}
#[test]
fn two_or_more_projects_list_with_a_link_each() {
let up = up_many(&["index-list-a", "index list b"]);
let boot = call(up.port(), &up.boot_path(), &[("Host", up.host())]);
let token = token_from(&boot);
let a = call(
up.port(),
"/",
&[("Host", up.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
let hrefs = hrefs_in(&a.body);
assert_eq!(hrefs.len(), 2, "expected one link per project:\n{}", a.body);
assert!(
hrefs.iter().all(|h| !h.contains(' ')),
"a href carried a raw space, which an id can never have: {hrefs:?}"
);
for sandbox in &up._sandboxes {
let name = sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
assert!(
a.body.contains(&name),
"the link text does not show the raw name {name}:\n{}",
a.body
);
}
}
#[test]
fn a_project_row_carries_its_own_class() {
let s = up("project-row-class");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let a = call(
s.port(),
"/",
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(a.body.contains("class=\"project\""), "{}", a.body);
}
#[test]
fn a_projects_today_page_serves_with_its_focus_on_it() {
let s = up("today");
s._sandbox
.ok(&["push", "Fix the cache adapter", "--why", "the bug needs it"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert_eq!(a.header("Content-Type"), Some("text/html; charset=utf-8"));
assert!(a.body.contains("Fix the cache adapter"), "{}", a.body);
assert!(a.body.contains("you are here"), "{}", a.body);
assert!(a.body.contains("What moved"), "{}", a.body);
}
#[test]
fn a_page_shows_the_name_the_product_was_given_over_its_folders() {
let sandbox = Sandbox::new_empty("web-named");
sandbox.ok(&["init", "--yes", "--name", "IQuorum"]);
sandbox.ok(&["push", "Fix the cache adapter", "--why", "the bug needs it"]);
let server = Server::start(&sandbox);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let a = call(
server.port,
"/p/IQuorum/",
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(a.body.contains("IQuorum"), "{}", a.body);
let folder = sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
assert!(
!a.body.contains(&folder),
"the folder's name reached the page beside the product's: {folder}\n{}",
a.body
);
}
#[test]
fn the_index_lists_a_project_under_the_name_it_was_given() {
let sandbox = Sandbox::new_empty("web-named-index");
sandbox.ok(&["init", "--yes", "--name", "IQuorum"]);
let server = Server::start_serving(
&std::env::temp_dir(),
sandbox.global_home(),
&[sandbox.0.as_path()],
);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let a = call(
server.port,
"/",
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(a.body.contains("IQuorum"), "{}", a.body);
let folder = sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
assert!(
!a.body.contains(&folder),
"the index named it by its folder: {folder}\n{}",
a.body
);
}
#[test]
fn the_served_page_reaches_for_nothing_off_this_machine() {
let s = up("today-offline");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(!a.body.contains("http://"), "{}", a.body);
assert!(!a.body.contains("https://"), "{}", a.body);
}
#[test]
fn a_project_id_the_registry_does_not_hold_is_not_found() {
let s = up("today-unknown");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let a = call(
s.port(),
"/p/not-a-project/",
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 404, "{}", a.body);
assert_eq!(a.body, "not found\n");
}
#[test]
fn an_unknown_route_is_not_found_and_not_the_index() {
let s = up("unknown-route");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let a = call(
s.port(),
"/does-not-exist",
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 404, "{}", a.body);
assert_eq!(
a.body, "not found\n",
"a 404 leaked something else:\n{}",
a.body
);
}
#[test]
fn no_token_at_all_is_refused() {
let s = up("no-token");
let a = call(s.port(), "/", &[("Host", s.host())]);
assert_eq!(a.status, 401, "{}", a.body);
}
#[test]
fn a_foreign_host_is_refused_even_with_the_right_token() {
let s = up("foreign-host");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let a = call(
s.port(),
"/",
&[("Host", "malo.com".to_string()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 403, "{}", a.body);
}
#[test]
fn a_foreign_origin_is_refused() {
let s = up("foreign-origin");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let a = call(
s.port(),
"/",
&[
("Host", s.host()),
("Origin", "http://malo.com".to_string()),
("X-Vivac-Token", token),
],
);
assert_eq!(a.status, 403, "{}", a.body);
}
#[test]
fn no_response_ever_carries_a_cors_header() {
let s = up("no-cors");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let served = call(
s.port(),
"/",
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
let refused = call(s.port(), "/", &[("Host", s.host())]);
let foreign = call(s.port(), "/", &[("Host", "malo.com".to_string())]);
for a in [&boot, &served, &refused, &foreign] {
assert!(
a.header("Access-Control-Allow-Origin").is_none(),
"a response carried a CORS header:\n{:?}",
a.headers
);
}
}
#[test]
fn every_response_carries_the_security_headers() {
let s = up("security-headers");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let served = call(
s.port(),
"/",
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
let refused = call(s.port(), "/", &[("Host", s.host())]);
let foreign = call(s.port(), "/", &[("Host", "malo.com".to_string())]);
for a in [&boot, &served, &refused, &foreign] {
assert!(
a.header("Content-Security-Policy").is_some(),
"no CSP on a {} response",
a.status
);
assert_eq!(
a.header("Referrer-Policy"),
Some("no-referrer"),
"the boot url carries the key in its query string, and this header \
is what keeps it out of a `Referer`: {:?}",
a.headers
);
}
}
#[test]
fn the_lineage_of_a_node_is_served_with_a_step_per_ancestor() {
let s = up("why-page");
s._sandbox
.ok(&["push", "Ship the thing", "--why", "it is the goal"]);
s._sandbox
.ok(&["push", "The face is web", "--why", "the owner asked"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/why/2"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert_eq!(a.header("Content-Type"), Some("text/html; charset=utf-8"));
assert!(a.body.contains("Ship the thing"), "{}", a.body);
assert!(a.body.contains("The face is web"), "{}", a.body);
assert!(a.body.contains("you are here"), "{}", a.body);
assert!(
hrefs_in(&a.body).iter().any(|h| h.contains("/why/")),
"{}",
a.body
);
assert!(!a.body.contains("http://"), "{}", a.body);
assert!(!a.body.contains("https://"), "{}", a.body);
}
#[test]
fn a_lineage_for_a_node_that_is_not_there_is_not_found() {
let s = up("why-unknown");
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/why/f999"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 404, "{}", a.body);
assert_eq!(a.body, "not found\n");
}
#[test]
fn a_browser_that_only_carries_the_cookie_can_walk_from_today_to_a_lineage() {
let s = up("browsing");
s._sandbox
.ok(&["push", "Ship the thing", "--why", "it is the goal"]);
s._sandbox
.ok(&["push", "The face is web", "--why", "the owner asked"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let jar = format!("vivac_session={}", token_from(&boot));
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let today = call(
s.port(),
&format!("/p/{id}/"),
&[("Host", s.host()), ("Cookie", jar.clone())],
);
assert_eq!(today.status, 200, "{}", today.body);
let lineage = hrefs_in(&today.body)
.into_iter()
.find(|h| h.contains("/why/"))
.unwrap_or_else(|| {
panic!(
"no lineage link on the Today page:
{}",
today.body
)
});
let a = call(s.port(), &lineage, &[("Host", s.host()), ("Cookie", jar)]);
assert_eq!(a.status, 200, "{} -> {}", lineage, a.body);
assert!(a.body.contains("you are here"), "{}", a.body);
}
#[test]
fn the_tree_page_routes_with_or_without_a_slash_and_a_bogus_project_is_still_not_found() {
let s = up("tree-route");
s._sandbox
.ok(&["push", "Ship the thing", "--why", "it is the goal"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
for path in [format!("/p/{id}/tree"), format!("/p/{id}/tree/")] {
let a = call(
s.port(),
&path,
&[("Host", s.host()), ("X-Vivac-Token", token.clone())],
);
assert_eq!(a.status, 200, "{path}: {}", a.body);
}
let bogus = call(
s.port(),
"/p/not-a-project/tree",
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(bogus.status, 404, "{}", bogus.body);
}
#[test]
fn every_node_is_one_row_that_still_reaches_its_own_lineage() {
let s = up("map-rows");
s._sandbox
.ok(&["push", "Root goal", "--why", "it is the goal"]);
s._sandbox.ok(&[
"add",
"A branch",
"--why",
"it needs its own children",
"--parent",
"1",
]);
s._sandbox.ok(&[
"add",
"A leaf beside it",
"--why",
"it stands alone",
"--parent",
"1",
]);
s._sandbox.ok(&[
"add",
"A leaf below the branch",
"--why",
"it hangs off the branch",
"--parent",
"2",
]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/tree"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
for alias in ["g1", "t2", "t3", "t4"] {
let needle = format!("href=\"/p/{id}/why/{alias}\"");
assert_eq!(
a.body.matches(&needle).count(),
1,
"{alias} should reach its own lineage exactly once:\n{}",
a.body
);
assert_eq!(
a.body.matches(&format!("id=\"{alias}\"")).count(),
1,
"{alias} should be one row and no more:\n{}",
a.body
);
}
}
#[test]
fn the_map_is_one_flat_list_with_a_row_for_every_node() {
let s = up("map-flat");
s._sandbox
.ok(&["push", "Root goal", "--why", "it is the goal"]);
s._sandbox.ok(&[
"add",
"A branch",
"--why",
"it needs its own children",
"--parent",
"1",
]);
s._sandbox.ok(&[
"add",
"A leaf beside it",
"--why",
"it stands alone",
"--parent",
"1",
]);
s._sandbox.ok(&[
"add",
"A leaf below the branch",
"--why",
"it hangs off the branch",
"--parent",
"2",
]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/tree"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert_eq!(
a.body.matches("<ol class=\"stops\">").count(),
1,
"a second list is a nest, and a nest moves a row off its station:\n{}",
a.body
);
assert_eq!(a.body.matches("<li class=\"stop").count(), 4, "{}", a.body);
for (row, alias) in ["g1", "t2", "t4", "t3"].iter().enumerate() {
assert!(
a.body
.contains(&format!("id=\"{alias}\" data-stop=\"{row}\"")),
"{alias} should be row {row}:\n{}",
a.body
);
assert_eq!(
a.body.matches(&format!("data-stop=\"{row}\" cx=")).count(),
2,
"one station in each of the two gutters:\n{}",
a.body
);
}
}
#[test]
fn the_row_counts_the_fan_out_the_radius_can_no_longer_tell_apart() {
let s = up("map-fan");
s._sandbox
.ok(&["push", "Root goal", "--why", "it is the goal"]);
s._sandbox.ok(&[
"add",
"A branch",
"--why",
"it needs its own children",
"--parent",
"1",
]);
s._sandbox.ok(&[
"add",
"A leaf beside it",
"--why",
"it stands alone",
"--parent",
"1",
]);
s._sandbox.ok(&[
"add",
"A leaf below the branch",
"--why",
"it hangs off the branch",
"--parent",
"2",
]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/tree"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(
a.body.contains("<span class=\"fan\">2</span>"),
"{}",
a.body
);
assert_eq!(
a.body.matches("<span class=\"fan\">1</span>").count(),
1,
"{}",
a.body
);
assert_eq!(a.body.matches("class=\"fan\"").count(), 2, "{}", a.body);
}
#[test]
fn state_reaches_the_row_as_a_word_in_its_title_attribute_not_only_as_a_class() {
let s = up("map-state-words");
s._sandbox
.ok(&["push", "Root goal", "--why", "it is the goal"]);
s._sandbox.ok(&[
"add",
"Still open",
"--why",
"nothing settled it yet",
"--parent",
"1",
]);
s._sandbox.ok(&[
"add",
"Already closed",
"--why",
"it shipped",
"--parent",
"1",
]);
s._sandbox.ok(&["done", "3", "shipped it"]);
s._sandbox.ok(&[
"add",
"Parked for later",
"--why",
"it is not due yet",
"--parent",
"1",
]);
s._sandbox.ok(&["park", "4", "waiting on the release"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/tree"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(
a.body.contains("title=\"t2 · open · Still open\""),
"{}",
a.body
);
assert!(
a.body.contains("title=\"t3 · closed · Already closed\""),
"{}",
a.body
);
assert!(
a.body.contains("title=\"t4 · parked · Parked for later\""),
"{}",
a.body
);
assert!(a.body.contains("<li class=\"stop shut\""), "{}", a.body);
}
#[test]
fn the_detail_of_every_node_arrives_with_the_page() {
let s = up("map-detail");
s._sandbox
.ok(&["push", "Root goal", "--why", "it is the goal"]);
s._sandbox.ok(&[
"add",
"A branch",
"--why",
"it needs its own children",
"--parent",
"1",
]);
s._sandbox.ok(&["note", "2", "the first note"]);
s._sandbox
.ok(&["note", "2", "and the one that corrects it"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/tree"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(
a.body
.contains("<script type=\"application/json\" id=\"map-data\">"),
"{}",
a.body
);
for why in ["it is the goal", "it needs its own children"] {
assert!(
a.body.contains(why),
"{why} is not on the page:\n{}",
a.body
);
}
assert!(a.body.contains("the first note"), "{}", a.body);
assert!(
a.body.contains("and the one that corrects it"),
"{}",
a.body
);
assert!(a.body.contains("2 notes"), "{}", a.body);
}
#[test]
fn a_fold_travels_in_the_url_and_is_served_like_any_other_page() {
let s = up("map-fold");
s._sandbox
.ok(&["push", "Root goal", "--why", "it is the goal"]);
s._sandbox.ok(&[
"add",
"A branch",
"--why",
"it needs its own children",
"--parent",
"1",
]);
s._sandbox.ok(&[
"add",
"A leaf below the branch",
"--why",
"it hangs off the branch",
"--parent",
"2",
]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let headers = [("Host", s.host()), ("X-Vivac-Token", token)];
let whole = call(s.port(), &format!("/p/{id}/tree"), &headers);
assert_eq!(whole.status, 200, "{}", whole.body);
assert_eq!(whole.body.matches("<li class=\"stop").count(), 3);
let folded = call(s.port(), &format!("/p/{id}/tree?fold=t2"), &headers);
assert_eq!(folded.status, 200, "{}", folded.body);
assert_eq!(
folded.body.matches("<li class=\"stop").count(),
2,
"t2's own child should be off the page:\n{}",
folded.body
);
assert!(
folded.body.contains("<span class=\"held\">+1</span>"),
"{}",
folded.body
);
assert_eq!(folded.body.matches("class=\"fold on\"").count(), 2);
}
#[test]
fn the_map_reaches_for_nothing_off_this_machine() {
let s = up("tree-offline");
s._sandbox
.ok(&["push", "Ship the thing", "--why", "it is the goal"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let a = call(
s.port(),
&format!("/p/{id}/tree"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(!a.body.contains("http://"), "{}", a.body);
assert!(!a.body.contains("https://"), "{}", a.body);
for reaching in ["src=", "@import", "<use", "<image", "url("] {
assert!(
!a.body.contains(reaching),
"{reaching} would fetch something:\n{}",
a.body
);
}
}
#[test]
fn a_browser_that_only_carries_the_cookie_can_walk_from_today_to_the_tree() {
let s = up("tree-browsing");
s._sandbox
.ok(&["push", "Ship the thing", "--why", "it is the goal"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let jar = format!("vivac_session={}", token_from(&boot));
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let today = call(
s.port(),
&format!("/p/{id}/"),
&[("Host", s.host()), ("Cookie", jar.clone())],
);
assert_eq!(today.status, 200, "{}", today.body);
let tree_link = hrefs_in(&today.body)
.into_iter()
.find(|h| h.contains("/tree"))
.unwrap_or_else(|| {
panic!(
"no link to the tree on the Today page:
{}",
today.body
)
});
let a = call(s.port(), &tree_link, &[("Host", s.host()), ("Cookie", jar)]);
assert_eq!(a.status, 200, "{} -> {}", tree_link, a.body);
}
fn first_event_id(root: &std::path::Path) -> String {
let log = std::fs::read_to_string(root.join(".vivac").join("events")).unwrap();
let line = log.lines().next().expect("a tree with no events has no id");
let at = line.find("\"id\":\"").expect("no id on the first event") + 6;
let rest = &line[at..];
rest[..rest.find('"').unwrap()].to_string()
}
fn twins(name: &str) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) {
let home = std::env::temp_dir().join(format!("t-home-twins-{name}"));
std::fs::create_dir_all(&home).unwrap();
let mut roots = Vec::new();
for side in ["a", "b"] {
let d = std::env::temp_dir()
.join(format!("t-twins-{name}-{side}"))
.join("same-name");
std::fs::create_dir_all(&d).unwrap();
let ok = Command::new(BIN)
.current_dir(&d)
.env("VIVAC_HOME", &home)
.env("TZ", "UTC")
.args(["init", "--yes"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success();
assert!(ok, "init failed in a twin");
roots.push(d);
}
(home, roots[0].clone(), roots[1].clone())
}
#[test]
fn a_dead_registry_entry_does_not_stop_the_server() {
let live = Sandbox::new_seeded("dead-reg-live");
let dead = Sandbox::new_seeded_in("dead-reg-dead", live.global_home());
let dead_name = dead.0.file_name().unwrap().to_string_lossy().into_owned();
std::fs::remove_dir_all(&dead.0).unwrap();
let mut server = Server::start_serving(&std::env::temp_dir(), live.global_home(), &[]);
assert_eq!(
server.next_startup_line(),
format!("1 project unreachable: {dead_name}")
);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let a = call(
server.port,
"/",
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(
a.body
.contains(&format!("1 project unreachable: {dead_name}")),
"{}",
a.body
);
}
#[test]
fn a_project_flag_naming_nothing_on_disk_still_fails() {
let sandbox = Sandbox::new_empty("bad-explicit-project");
let missing = sandbox.0.join("nowhere");
let output = Command::new(BIN)
.current_dir(std::env::temp_dir())
.env("VIVAC_HOME", sandbox.global_home())
.env("TZ", "UTC")
.args([
"web",
"--port",
"0",
"--no-open",
"--project",
missing.to_str().unwrap(),
])
.output()
.unwrap();
assert_ne!(
output.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!String::from_utf8_lossy(&output.stdout).contains("http://"),
"printed a boot url despite a bad --project:\n{}",
String::from_utf8_lossy(&output.stdout)
);
}
#[test]
fn started_outside_a_project_the_index_is_what_answers() {
let one = Sandbox::new_seeded("outside-one");
let two = Sandbox::new_seeded_in("outside-two", one.global_home());
let server = Server::start_serving(
&std::env::temp_dir(),
one.global_home(),
&[one.0.as_path(), two.0.as_path()],
);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let a = call(
server.port,
"/",
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
let one_name = one.0.file_name().unwrap().to_string_lossy().into_owned();
let two_name = two.0.file_name().unwrap().to_string_lossy().into_owned();
assert!(a.body.contains(&one_name), "{}", a.body);
assert!(a.body.contains(&two_name), "{}", a.body);
}
#[test]
fn the_index_is_a_page_and_carries_what_d200_admitted() {
let one = Sandbox::new_seeded("index-fields-one");
let two = Sandbox::new_seeded_in("index-fields-two", one.global_home());
two.ok(&["push", "Ship the thing", "--why", "it is the goal"]);
let server = Server::start_serving(
&std::env::temp_dir(),
one.global_home(),
&[one.0.as_path(), two.0.as_path()],
);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let a = call(
server.port,
"/",
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(a.body.contains("<title>"), "no title: {}", a.body);
assert!(a.body.contains("<style>"), "no stylesheet: {}", a.body);
assert!(
a.body.contains("width=device-width"),
"no viewport: {}",
a.body
);
assert!(a.body.contains("Ship the thing"), "no focus: {}", a.body);
assert!(a.body.contains("moved today"), "no silence: {}", a.body);
assert!(a.body.contains("no focus"), "{}", a.body);
}
#[test]
fn the_index_card_says_whether_work_followed_the_stop_you_made() {
let s = up("index-card-stop-since");
s._sandbox.ok(&[
"add",
"Something",
"--why",
"so the stop has work behind it",
]);
s._sandbox.ok(&["save", "checkpoint"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let a = call(
s.port(),
"/",
&[("Host", s.host()), ("X-Vivac-Token", token.clone())],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(a.body.contains("nothing since"), "{}", a.body);
s._sandbox.ok(&[
"add",
"After the stop",
"--why",
"so the hook has something to see",
]);
let b = call(
s.port(),
"/",
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(b.status, 200, "{}", b.body);
assert!(b.body.contains("work since"), "{}", b.body);
}
#[test]
fn a_project_opens_by_its_permanent_id() {
let s = up("permanent-id");
s._sandbox
.ok(&["push", "Root goal", "--why", "it is the goal"]);
let ulid = first_event_id(&s._sandbox.0);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let a = call(
s.port(),
&format!("/p/{ulid}/"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 200, "{}", a.body);
assert!(a.body.contains("Root goal"), "{}", a.body);
}
#[test]
fn a_name_two_projects_share_is_a_choice_and_not_a_guess() {
let (home, a_root, b_root) = twins("choice");
let server = Server::start_serving(
&std::env::temp_dir(),
&home,
&[a_root.as_path(), b_root.as_path()],
);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let a = call(
server.port,
"/p/same-name/",
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(a.status, 300, "{}", a.body);
assert!(!a.body.contains("same-name-2"), "{}", a.body);
}
#[test]
fn the_choice_tells_them_apart_without_naming_a_path() {
let (home, a_root, b_root) = twins("no-paths");
let server = Server::start_serving(
&std::env::temp_dir(),
&home,
&[a_root.as_path(), b_root.as_path()],
);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let a = call(
server.port,
"/p/same-name/",
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
for root in [&a_root, &b_root] {
let parent = root.parent().unwrap().to_string_lossy().into_owned();
assert!(
!a.body.contains(&parent),
"a path reached the page: {}",
a.body
);
assert!(
!a.body.contains(&parent.replace('\\', "/")),
"a path reached the page: {}",
a.body
);
}
}
fn join_lane(on: &Sandbox, folder: &str, name: &str) -> Sandbox {
let joined = Sandbox::new_empty_in(folder, on.global_home());
joined.ok(&[
"init",
"--yes",
"--join",
on.0.to_str().unwrap(),
"--lane-name",
name,
]);
joined
}
fn stop_of_title(body: &str, title: &str) -> String {
let marker = format!("<span class=\"title\">{title}</span>");
let at = body
.find(&marker)
.unwrap_or_else(|| panic!("no row titled {title}:\n{body}"));
let before = &body[..at];
let data_stop = "data-stop=\"";
let start = before
.rfind(data_stop)
.unwrap_or_else(|| panic!("no data-stop before {title}:\n{body}"))
+ data_stop.len();
let rest = &before[start..];
rest[..rest.find('"').unwrap()].to_string()
}
fn here_stop(body: &str) -> Option<String> {
let marker = "id=\"here\" type=\"button\" data-stop=\"";
let start = body.find(marker)? + marker.len();
let rest = &body[start..];
Some(rest[..rest.find('"')?].to_string())
}
#[test]
fn with_one_lane_the_web_says_exactly_what_it_did() {
let s = up("web-lanes-one");
s._sandbox
.ok(&["push", "Fix the cache adapter", "--why", "the bug needs it"]);
let boot = call(s.port(), &s.boot_path(), &[("Host", s.host())]);
let token = token_from(&boot);
let id = s
._sandbox
.0
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let today = call(
s.port(),
&format!("/p/{id}/"),
&[("Host", s.host()), ("X-Vivac-Token", token.clone())],
);
assert_eq!(today.status, 200, "{}", today.body);
assert!(today.body.contains("you are here"), "{}", today.body);
assert!(!today.body.contains("in lane"), "{}", today.body);
let map = call(
s.port(),
&format!("/p/{id}/tree"),
&[("Host", s.host()), ("X-Vivac-Token", token)],
);
assert_eq!(map.status, 200, "{}", map.body);
assert!(map.body.contains("Where am I?"), "{}", map.body);
}
#[test]
fn with_several_lanes_the_front_page_names_whose_focus_it_is() {
let a = Sandbox::new_seeded("web-lanes-front-a");
a.ok(&["push", "Main line work", "--why", "seed"]);
let b = join_lane(&a, "web-lanes-front-b", "hotfix");
b.ok(&["push", "Hotfix triage", "--why", "seed"]);
let c = join_lane(&a, "web-lanes-front-c", "sonar");
c.ok(&["push", "Backend webapi cleanup", "--why", "seed"]);
let server = Server::start(&a);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let id = a.0.file_name().unwrap().to_string_lossy().into_owned();
let today = call(
server.port,
&format!("/p/{id}/"),
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(today.status, 200, "{}", today.body);
assert!(
today.body.contains("Backend webapi cleanup"),
"{}",
today.body
);
assert!(
today.body.contains("in lane sonar, 1 of 3 lanes"),
"{}",
today.body
);
assert!(!today.body.contains("you are here"), "{}", today.body);
}
#[test]
fn the_map_points_at_the_lane_of_the_folder_the_web_was_opened_from() {
let a = Sandbox::new_seeded("web-lanes-here-a");
a.ok(&["push", "Main line work", "--why", "seed"]);
let b = join_lane(&a, "web-lanes-here-b", "sonar");
b.ok(&["push", "Backend webapi cleanup", "--why", "seed"]);
let c = join_lane(&a, "web-lanes-here-c", "hotfix");
c.ok(&["push", "Hotfix triage", "--why", "seed"]);
let server = Server::start_serving(&b.0, a.global_home(), &[]);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let id = a.0.file_name().unwrap().to_string_lossy().into_owned();
let map = call(
server.port,
&format!("/p/{id}/tree"),
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(map.status, 200, "{}", map.body);
let here = here_stop(&map.body).unwrap_or_else(|| panic!("no here control:\n{}", map.body));
let b_stop = stop_of_title(&map.body, "Backend webapi cleanup");
assert_eq!(here, b_stop, "{}", map.body);
}
#[test]
fn the_map_falls_back_to_the_last_writer_and_says_so() {
let a = Sandbox::new_seeded("web-lanes-fallback-a");
a.ok(&["push", "Seed the tree's own identity", "--why", "seed"]);
a.ok(&["done", "g1", "not main's own thread"]);
let b = join_lane(&a, "web-lanes-fallback-b", "sonar");
b.ok(&["push", "Backend webapi cleanup", "--why", "seed"]);
let c = join_lane(&a, "web-lanes-fallback-c", "hotfix");
c.ok(&["push", "Hotfix triage", "--why", "seed"]);
let server = Server::start_serving(&std::env::temp_dir(), a.global_home(), &[a.0.as_path()]);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let id = a.0.file_name().unwrap().to_string_lossy().into_owned();
let map = call(
server.port,
&format!("/p/{id}/tree"),
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(map.status, 200, "{}", map.body);
assert!(!map.body.contains("Where am I?"), "{}", map.body);
assert!(
map.body
.contains("Where the last write is (not where you are)"),
"{}",
map.body
);
let here = here_stop(&map.body).unwrap_or_else(|| panic!("no fallback control:\n{}", map.body));
let c_stop = stop_of_title(&map.body, "Hotfix triage");
assert_eq!(here, c_stop, "{}", map.body);
}
#[test]
fn the_index_names_whose_focus_it_is_too() {
let a = Sandbox::new_seeded("web-lanes-index-a");
a.ok(&["push", "Main line work", "--why", "seed"]);
let b = join_lane(&a, "web-lanes-index-b", "hotfix");
b.ok(&["push", "Hotfix triage", "--why", "seed"]);
let c = join_lane(&a, "web-lanes-index-c", "sonar");
c.ok(&["push", "Backend webapi cleanup", "--why", "seed"]);
let other = Sandbox::new_seeded_in("web-lanes-index-other", a.global_home());
let server = Server::start_serving(
&std::env::temp_dir(),
a.global_home(),
&[a.0.as_path(), other.0.as_path()],
);
let boot = call(server.port, &server.boot_path(), &[("Host", server.host())]);
let token = token_from(&boot);
let index = call(
server.port,
"/",
&[("Host", server.host()), ("X-Vivac-Token", token)],
);
assert_eq!(index.status, 200, "{}", index.body);
assert!(
index.body.contains("Backend webapi cleanup"),
"{}",
index.body
);
assert!(
index.body.contains("in lane sonar, 1 of 3 lanes"),
"{}",
index.body
);
}