tests/web_rendering_test.rs
Ref: Size: 17.6 KiB History
//! What the web UI actually puts on the page, driven through a live server.
//!
//! These cover three defects found by reading the rendered pages rather than
//! the templates — none of them is a malformed-HTML bug, so every existing
//! content assertion passed while all three were live:
//!
//! * `b2a57996` — timestamps printed as raw RFC3339 with nanoseconds, and a
//! `Merged in` oid printed at 40 characters beside revisions printed at 8.
//! * `c43c459d` — an empty *filtered* list claiming the repository is empty.
//! * `850f3b8e` — a Branch column naming worktree branches that were deleted
//! with the worktree.
//!
//! `--json` is asserted alongside each, because the fix is a *display* policy:
//! the HTML gets to be short, and the scripted surface must keep the full id
//! and the full timestamp it has always carried.
mod common;
use common::ServerHarness;
use serde_json::Value;
use std::process::Command;
/// A branch name of the shape agent worktrees produce and then delete.
const EPHEMERAL_BRANCH: &str = "worktree-agent-ab8e151c29539ab74";
/// The text a reader sees, with every tag — and so every attribute — removed.
///
/// The full timestamp is deliberately still in the page, inside `title=`, so a
/// substring search over the raw HTML cannot tell the fix from the defect. The
/// question is only ever what is rendered *between* the tags.
fn visible_text(body: &str) -> String {
let mut text = String::with_capacity(body.len());
let mut in_tag = false;
for c in body.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => text.push(c),
_ => {}
}
}
text
}
/// Whether any visible text is an RFC3339 timestamp with a fractional second —
/// the 35-character shape issue `b2a57996` reported.
fn has_nanosecond_timestamp(body: &str) -> bool {
visible_text(body).split('T').skip(1).any(|rest| {
let head: String = rest.chars().take(16).collect();
head.len() >= 10 && head.as_bytes()[2] == b':' && head.contains('.')
})
}
fn patch_on_ephemeral_branch(harness: &ServerHarness, title: &str, branch: &str) -> String {
let repo = harness.work_repo();
repo.git(&["checkout", "-b", branch]);
repo.commit_file(
&format!("{branch}.rs"),
"pub fn work() {}\n",
&format!("work on {branch}"),
);
let out = repo.run_ok(&["patch", "create", "-t", title, "-B", branch]);
repo.git(&["checkout", "main"]);
out.trim()
.strip_prefix("Created patch ")
.expect("patch create prints the id")
.to_string()
}
fn patch_json(harness: &ServerHarness, id: &str) -> Value {
serde_json::from_str(&harness.work_repo().run_ok(&["patch", "show", id, "--json"])).unwrap()
}
fn issue_json(harness: &ServerHarness, id: &str) -> Value {
serde_json::from_str(&harness.work_repo().run_ok(&["issue", "show", id, "--json"])).unwrap()
}
// ---------------------------------------------------------------------------
// b2a57996 — timestamps and the `Merged in` oid
// ---------------------------------------------------------------------------
#[test]
fn list_timestamps_are_short_with_the_full_value_on_hover() {
let harness = ServerHarness::new("render-timestamps");
let patch_id = patch_on_ephemeral_branch(&harness, "A patch with a timestamp", "feat/stamped");
let issue_id = harness.work_repo().issue_open("An issue with a timestamp");
harness.push_head();
harness.push_collab_refs();
let full_patch_stamp = patch_json(&harness, &patch_id)["last_updated"]
.as_str()
.unwrap()
.to_string();
let full_issue_stamp = issue_json(&harness, &issue_id)["last_updated"]
.as_str()
.unwrap()
.to_string();
for (path, full) in [
(
format!("/{}/patches", harness.repo_name()),
&full_patch_stamp,
),
(
format!("/{}/issues", harness.repo_name()),
&full_issue_stamp,
),
] {
let body = harness.get_ok(&path).body;
assert!(
!has_nanosecond_timestamp(&body),
"{path} still renders a nanosecond timestamp:\n{body}"
);
assert!(
body.contains(&format!("title=\"{full}\"")),
"{path} drops the full timestamp instead of keeping it on hover"
);
// The short rendering: date and minute, no seconds, no nanoseconds.
let short = &full[..16].replace('T', " ");
assert!(
body.contains(&format!(">{short}<")),
"{path} does not render the short timestamp '{short}'"
);
}
}
#[test]
fn patch_detail_timestamps_are_short_with_the_full_value_on_hover() {
let harness = ServerHarness::new("render-detail-stamps");
let patch_id = patch_on_ephemeral_branch(&harness, "Reviewed patch", "feat/reviewed");
harness.work_repo().run_ok(&[
"patch", "review", &patch_id, "-v", "approve", "-b", "Looks ok",
]);
harness
.work_repo()
.run_ok(&["patch", "comment", &patch_id, "-b", "A thread comment"]);
harness.push_head();
harness.push_collab_refs();
let body = harness
.get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id))
.body;
assert!(
!has_nanosecond_timestamp(&body),
"the patch detail page still renders nanosecond timestamps:\n{body}"
);
let json = patch_json(&harness, &patch_id);
let revision_stamp = json["revisions"][0]["timestamp"].as_str().unwrap();
assert!(
body.contains(&format!("title=\"{revision_stamp}\"")),
"the revision timestamp is not available on hover"
);
}
#[test]
fn merged_in_is_abbreviated_like_the_revision_commits_beside_it() {
let harness = ServerHarness::new("render-merged-in");
let patch_id = patch_on_ephemeral_branch(&harness, "A landed patch", "feat/landed");
let repo = harness.work_repo();
repo.git(&["merge", "--ff-only", "feat/landed"]);
repo.run_ok(&["patch", "merge", &patch_id]);
harness.push_head();
harness.push_collab_refs();
let merge_commit = patch_json(&harness, &patch_id)["merge_commit"]
.as_str()
.unwrap()
.to_string();
assert_eq!(merge_commit.len(), 40, "the event records the full oid");
let body = harness
.get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id))
.body;
// The link still goes to the full oid; only the text is abbreviated.
assert!(
body.contains(&format!("/diff/{merge_commit}")),
"the merge link no longer resolves the full commit"
);
assert!(
body.contains(&format!(">{}<", &merge_commit[..8])),
"'Merged in' does not render the 8-character oid the revisions use"
);
assert!(
!body.contains(&format!(">{merge_commit}<")),
"'Merged in' still renders the full 40-character oid"
);
}
// ---------------------------------------------------------------------------
// c43c459d — an empty filtered list must not claim the repository is empty
// ---------------------------------------------------------------------------
#[test]
fn an_empty_open_list_names_the_filter_and_offers_the_way_out() {
let harness = ServerHarness::new("render-empty-filtered");
let patch_id = patch_on_ephemeral_branch(&harness, "A closed patch", "feat/closed");
harness.work_repo().patch_close(&patch_id);
let issue_id = harness.work_repo().issue_open("A closed issue");
harness.work_repo().issue_close(&issue_id);
harness.push_head();
harness.push_collab_refs();
let patches = harness
.get_ok(&format!("/{}/patches", harness.repo_name()))
.body;
assert!(
!patches.contains("No patches."),
"the patch list still claims the repository has no patches"
);
assert!(
patches.contains("No open patches"),
"the patch list does not say which filter was empty:\n{patches}"
);
assert!(
patches.contains("1 total"),
"the patch list does not say how many patches there are:\n{patches}"
);
assert!(
patches.contains("patches?filter=all"),
"the patch list offers no way to see the rest"
);
let issues = harness
.get_ok(&format!("/{}/issues", harness.repo_name()))
.body;
assert!(
!issues.contains("No issues."),
"the issue list still claims the repository has no issues"
);
assert!(
issues.contains("No open issues") && issues.contains("1 total"),
"the issue list does not say which filter was empty:\n{issues}"
);
assert!(
issues.contains("issues?filter=all"),
"the issue list offers no way to see the rest"
);
}
#[test]
fn a_genuinely_empty_repository_still_says_so_plainly() {
let harness = ServerHarness::new("render-empty-repo");
harness.push_head();
let patches = harness
.get_ok(&format!("/{}/patches", harness.repo_name()))
.body;
assert!(
patches.contains("No patches yet."),
"an empty repository should say so without offering a filter:\n{patches}"
);
assert!(
!patches.contains("total"),
"an empty repository should not offer to show zero more patches"
);
let issues = harness
.get_ok(&format!("/{}/issues", harness.repo_name()))
.body;
assert!(
issues.contains("No issues yet."),
"an empty repository should say so without offering a filter:\n{issues}"
);
}
// ---------------------------------------------------------------------------
// 850f3b8e — the Branch column
// ---------------------------------------------------------------------------
#[test]
fn the_patch_list_shows_the_base_ref_not_the_ephemeral_branch() {
let harness = ServerHarness::new("render-branch-column");
patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);
harness.push_head();
harness.push_collab_refs();
let body = harness
.get_ok(&format!("/{}/patches?filter=all", harness.repo_name()))
.body;
assert!(
!body.contains(EPHEMERAL_BRANCH),
"the patch list still names a branch that no longer exists:\n{body}"
);
assert!(
body.contains("<th>Base</th>"),
"the patch list has no Base column:\n{body}"
);
assert!(
body.contains(">main<"),
"the patch list does not say what the patch targets:\n{body}"
);
}
/// Provenance still belongs somewhere — `patch show` is where, on both
/// surfaces. Dropping it from the list is not dropping it from the tool.
#[test]
fn the_patch_detail_page_still_records_the_branch_it_came_from() {
let harness = ServerHarness::new("render-branch-detail");
let patch_id = patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);
harness.push_head();
harness.push_collab_refs();
let body = harness
.get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id))
.body;
assert!(
body.contains(EPHEMERAL_BRANCH),
"the detail page lost the branch the patch came from"
);
}
#[test]
fn the_cli_patch_list_agrees_with_the_web_list_about_the_base_ref() {
let harness = ServerHarness::new("render-cli-agreement");
patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);
let listing = harness.work_repo().run_ok(&["patch", "list"]);
assert!(
!listing.contains(EPHEMERAL_BRANCH),
"`patch list` names a branch that no longer exists:\n{listing}"
);
assert!(
listing.contains("main"),
"`patch list` does not say what the patch targets:\n{listing}"
);
}
// ---------------------------------------------------------------------------
// The scripted surface is untouched by all of the above
// ---------------------------------------------------------------------------
#[test]
fn json_keeps_full_ids_timestamps_and_the_branch_the_html_no_longer_shows() {
let harness = ServerHarness::new("render-json-intact");
let patch_id = patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);
let listing: Value =
serde_json::from_str(&harness.work_repo().run_ok(&["patch", "list", "--json"])).unwrap();
let entry = &listing.as_array().unwrap()[0];
assert_eq!(
entry["id"].as_str().unwrap().len(),
40,
"--json abbreviated an id"
);
assert_eq!(
entry["branch"].as_str().unwrap(),
EPHEMERAL_BRANCH,
"--json dropped the branch, which is provenance a script may still want"
);
let stamp = entry["last_updated"].as_str().unwrap();
assert!(
stamp.contains('.') && stamp.len() > 25,
"--json truncated a timestamp to the display format: {stamp}"
);
let detail = patch_json(&harness, &patch_id);
assert_eq!(detail["id"].as_str().unwrap().len(), 40);
assert!(detail["revisions"][0]["timestamp"]
.as_str()
.unwrap()
.contains('.'));
}
// ---------------------------------------------------------------------------
// Rendering a page is a read
// ---------------------------------------------------------------------------
/// Every ref in the served repository, as `<name> <oid>` lines.
fn ref_snapshot(harness: &ServerHarness) -> String {
let bare = harness
.repos_dir()
.join(format!("{}.git", harness.repo_name()));
let output = Command::new("git")
.args(["for-each-ref", "--format=%(refname) %(objectname)"])
.current_dir(&bare)
.output()
.expect("failed to list refs");
assert!(output.status.success());
String::from_utf8(output.stdout).unwrap()
}
#[test]
fn rendering_pages_moves_no_refs_and_appends_no_events() {
let harness = ServerHarness::new("render-read-only");
let patch_id = patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH);
let issue_id = harness.work_repo().issue_open("An issue to read");
harness.push_head();
harness.push_collab_refs();
let before = ref_snapshot(&harness);
let name = harness.repo_name();
for path in [
format!("/{name}"),
format!("/{name}/patches"),
format!("/{name}/patches?filter=all"),
format!("/{name}/patches/{patch_id}"),
format!("/{name}/issues"),
format!("/{name}/issues?filter=all"),
format!("/{name}/issues/{issue_id}"),
format!("/{name}/commits"),
format!("/{name}/releases"),
] {
harness.get_ok(&path);
}
let after = ref_snapshot(&harness);
assert_eq!(
before, after,
"rendering pages changed the repository's refs"
);
}
/// A live claim belongs on the page: a claim nobody can see is a claim people
/// work around. Both the list and the detail view show the holder, and an
/// expired one shows nobody — `refs/collab/*` says nothing about either, so
/// this is the only place the lease store and the UI meet.
#[test]
fn a_live_claim_shows_on_the_issue_pages_and_an_expired_one_does_not() {
let harness = ServerHarness::new("render-claims");
harness.push_head();
let (_ref_name, full_id) = common::open_issue(
&harness.work_repo_git2(),
&common::alice(),
"An issue to claim",
);
harness.push_collab_refs();
let name = harness.repo_name().to_string();
let unclaimed_list = harness.get_ok(&format!("/{name}/issues"));
assert!(
unclaimed_list.body.contains("Claimed by"),
"the column should exist even with no claims"
);
let acquired = harness.ssh_exec(&format!(
"collab-lease acquire '{name}.git' '{full_id}' --ttl 600"
));
assert!(acquired.status.success());
let holder =
serde_json::from_str::<serde_json::Value>(String::from_utf8_lossy(&acquired.stdout).trim())
.unwrap()["holder"]
.as_str()
.unwrap()
.to_string();
let list = harness.get_ok(&format!("/{name}/issues"));
assert!(
list.body.contains(&holder),
"the list should name the holder {holder}: {}",
list.body
);
let detail = harness.get_ok(&format!("/{name}/issues/{full_id}"));
assert!(
detail.body.contains("Claimed by:") && detail.body.contains(&holder),
"the detail page should name the holder: {}",
detail.body
);
assert!(
detail.body.contains("expires"),
"a ttl claim should say when it lapses: {}",
detail.body
);
// A 1-second lease, waited out: the row survives (it carries the fencing
// token) but the page must stop showing a claim.
harness.ssh_exec(&format!("collab-lease release '{name}.git' '{full_id}'"));
harness.ssh_exec(&format!(
"collab-lease acquire '{name}.git' '{full_id}' --ttl 1"
));
std::thread::sleep(std::time::Duration::from_millis(1200));
let expired = harness.get_ok(&format!("/{name}/issues/{full_id}"));
assert!(
!expired.body.contains("Claimed by:"),
"an expired claim must not be shown: {}",
expired.body
);
}
/// An open-ended claim (no `--ttl`) is an assignment, so it must not claim to
/// expire.
#[test]
fn an_open_ended_claim_shows_no_expiry() {
let harness = ServerHarness::new("render-assigned");
harness.push_head();
let (_ref_name, full_id) = common::open_issue(
&harness.work_repo_git2(),
&common::alice(),
"An assigned issue",
);
harness.push_collab_refs();
let name = harness.repo_name().to_string();
harness.ssh_exec(&format!("collab-lease acquire '{name}.git' '{full_id}'"));
let detail = harness.get_ok(&format!("/{name}/issues/{full_id}"));
assert!(detail.body.contains("Claimed by:"));
assert!(
!detail.body.contains("expires"),
"an open-ended claim must not print an expiry: {}",
detail.body
);
}