//! The commit history viewer: every commit reachable from a revision.
//!
//! Like the refs browser tests, the content is put there with a real `git`
//! pushed straight into the bare repository the server created, so the page
//! is read out of the same repository a clone would fetch.
mod support;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use support::{Client, Server, free_port};
/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";
/// Names the scratch directories, so several tests can run at once.
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn spawn() -> (Server, u16) {
let port = free_port();
let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
server.wait_for_connection(port);
(server, port)
}
/// Registers `name` through the form, which logs them in.
fn register(client: &mut Client, name: &str) {
let email = format!("{name}@example.org");
let response = client.post_form(
"/register",
&[("name", name), ("email", &email), ("password", PASSWORD)],
);
assert_eq!(
response.status, 303,
"registering should log in\n{}",
response.body
);
}
/// Creates a repository through the form, and checks it landed where it should.
fn create(client: &mut Client, owner: &str, slug: &str, visibility: &str) {
let response = client.post_form(
&format!("/~{owner}/repositories"),
&[
("slug", slug),
("description", "a repository from the commits tests"),
("visibility", visibility),
],
);
assert_eq!(
response.status, 303,
"creating `{owner}/{slug}` should redirect\n{}",
response.body
);
}
/// A directory of the test's own, removed when it goes out of scope.
struct Scratch {
dir: PathBuf,
}
impl Scratch {
fn new(label: &str) -> Self {
let count = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("frogue-{label}-{}-{count}", std::process::id()));
fs::create_dir_all(&dir).expect("the scratch directory should be creatable");
Scratch { dir }
}
fn path(&self, name: &str) -> PathBuf {
self.dir.join(name)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
/// Runs `git` in `dir` with the test's own identity, with `env` set for this
/// call - the dated commits the listing order is asserted against.
fn git(dir: &Path, env: &[(&str, &str)], args: &[&str]) -> Output {
let mut command = Command::new("git");
command
.current_dir(dir)
.args(["-c", "user.name=Test", "-c", "user.email=test@example.org"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null");
for (name, value) in env {
command.env(name, value);
}
command.output().expect("git should run")
}
/// Fails with `git`'s own words when it did not succeed.
fn assert_ok(output: &Output, what: &str) {
assert!(
output.status.success(),
"{what} should succeed, but git exited with {:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
/// Fills `owner/slug`'s bare repository with a linear history of dated commits
/// on `main`, plus a branch with a slash in its name.
///
/// The dates are the test's to decide rather than the clock's, and they run
/// against the alphabetical order of the subjects on purpose: a listing sorted
/// by date reads `fourth, third, second, first`, one sorted by subject reads
/// `first, second, third, fourth`, so what the page did is the answer to
/// assert rather than a coincidence of the fixture.
fn populate(server: &Server, scratch: &Scratch, owner: &str, slug: &str) {
let bare = server.repo_root().join(owner).join(format!("{slug}.git"));
let work = scratch.path("work");
fs::create_dir_all(&work).expect("the working tree should be creatable");
assert_ok(
&git(&work, &[], &["init", "-q", "-b", "main"]),
"initialising",
);
// Four dated commits on main.
for (date, subject) in [
("2021-01-01T00:00:00+00:00", "first"),
("2021-01-02T00:00:00+00:00", "second"),
("2021-01-03T00:00:00+00:00", "third"),
("2021-01-04T00:00:00+00:00", "fourth"),
] {
let dates = [("GIT_AUTHOR_DATE", date), ("GIT_COMMITTER_DATE", date)];
assert_ok(
&git(
&work,
&dates,
&["commit", "-q", "--allow-empty", "-m", subject],
),
"committing",
);
}
// A branch with a slash in its name, one commit ahead of main.
assert_ok(
&git(&work, &[], &["checkout", "-q", "-b", "feature/x"]),
"branching",
);
let dates = [
("GIT_AUTHOR_DATE", "2021-01-05T00:00:00+00:00"),
("GIT_COMMITTER_DATE", "2021-01-05T00:00:00+00:00"),
];
assert_ok(
&git(
&work,
&dates,
&["commit", "-q", "--allow-empty", "-m", "on feature/x"],
),
"committing on feature/x",
);
assert_ok(
&git(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]),
"pushing",
);
}
/// The repository tab bar out of a page's body, which is what says which of
/// the repository's pages a visitor is on.
fn tab_bar(body: &str) -> &str {
let start = body
.find("<nav class=\"repository-tabs\">")
.expect("the page should carry the repository tab bar");
let rest = &body[start..];
let end = rest.find("</nav>").expect("the tab bar should be closed") + "</nav>".len();
&rest[..end]
}
#[tokio::test]
async fn the_commits_page_lists_every_commit_newest_first() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("commits-listing");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
let response = alice.get("/~alice/test-repo/commits/main");
assert_eq!(response.status, 200, "commits page");
let body = &response.body;
// All four commits, newest first, which for these subjects is the
// opposite of alphabetical.
assert!(
body.contains("Commits on main"),
"the heading, got:\n{body}"
);
assert!(
body.find("fourth").unwrap() < body.find("third").unwrap(),
"fourth before third, got:\n{body}"
);
assert!(
body.find("third").unwrap() < body.find("second").unwrap(),
"third before second, got:\n{body}"
);
assert!(
body.find("second").unwrap() < body.find("first").unwrap(),
"second before first, got:\n{body}"
);
// The commits tab should be current.
assert!(
tab_bar(body).contains(r#"aria-current="page">Commits</a>"#),
"the Commits tab should be current, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn commit_hashes_link_to_the_diff_at_that_commit() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("commits-hashes");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// Read the full hash of the "fourth" commit from git.
let work = scratch.path("work");
let output = git(&work, &[], &["rev-parse", "main"]);
assert_ok(&output, "reading the full hash");
let full = String::from_utf8_lossy(&output.stdout).trim().to_string();
let response = alice.get("/~alice/test-repo/commits/main");
assert_eq!(response.status, 200, "commits page");
let body = &response.body;
// The hash should be present and link to the diff at that commit.
assert!(
body.contains(&format!("/~alice/test-repo/commit/{full}")),
"the full hash should link to its diff, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn the_commits_root_redirects_to_the_default_branch() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("commits-redirect");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
let response = alice.get("/~alice/test-repo/commits");
assert_eq!(response.status, 303, "/commits should redirect");
assert_eq!(
response.header("location"),
Some("/~alice/test-repo/commits/main"),
"the redirect should lead to the default branch"
);
let response = alice.get("/~alice/test-repo/commits/");
assert_eq!(response.status, 303, "/commits/ should redirect");
assert_eq!(
response.header("location"),
Some("/~alice/test-repo/commits/main"),
"the redirect should lead to the default branch"
);
Ok(())
}
#[tokio::test]
async fn history_of_a_slashed_branch_works() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("commits-slashed");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
let response = alice.get("/~alice/test-repo/commits/feature/x");
assert_eq!(response.status, 200, "commits of feature/x");
let body = &response.body;
// The heading should mention feature/x.
assert!(
body.contains("Commits on feature/x"),
"the heading, got:\n{body}"
);
// The branch's own commit should be present, plus all of main's.
assert!(
body.contains("on feature/x"),
"the branch's commit, got:\n{body}"
);
assert!(
body.contains("fourth"),
"main's commits should be included, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn unknown_revision_is_404() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("commits-404");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
let response = alice.get("/~alice/test-repo/commits/nowhere");
assert_eq!(response.status, 404, "unknown revision");
Ok(())
}
#[tokio::test]
async fn tail_starting_with_dash_is_404() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("commits-dash");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
let response = alice.get("/~alice/test-repo/commits/-HEAD");
assert_eq!(response.status, 404, "tail starting with -");
Ok(())
}
#[tokio::test]
async fn unborn_default_branch_shows_empty_page() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
// The repository was just created and has no commits yet.
let response = alice.get("/~alice/test-repo/commits/main");
assert_eq!(response.status, 200, "unborn default branch");
let body = &response.body;
assert!(
body.contains("Nothing here yet."),
"an unborn branch should be an empty page, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn private_repo_anonymous_gets_404() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("commits-private");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "private");
populate(&server, &scratch, "alice", "test-repo");
// An anonymous visitor should get a 404, the same as for a missing repo.
let mut anonymous = Client::new(port);
let response = anonymous.get("/~alice/test-repo/commits/main");
assert_eq!(
response.status, 404,
"anonymous should get 404 for a private repo"
);
Ok(())
}
#[tokio::test]
async fn commits_root_with_ref_query_redirects_to_that_ref() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("commits-ref-query");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// Query parameter should redirect to the path-based URL.
let response = alice.get("/~alice/test-repo/commits/?ref=feature/x");
assert_eq!(response.status, 303, "/commits/?ref= should redirect");
assert_eq!(
response.header("location"),
Some("/~alice/test-repo/commits/feature/x"),
"the redirect should lead to the specified ref"
);
// Without trailing slash should also work.
let response = alice.get("/~alice/test-repo/commits?ref=main");
assert_eq!(response.status, 303, "/commits?ref= should redirect");
assert_eq!(
response.header("location"),
Some("/~alice/test-repo/commits/main"),
"the redirect should lead to the specified ref"
);
// Dash-prefixed ref should be rejected.
let response = alice.get("/~alice/test-repo/commits/?ref=-HEAD");
assert_eq!(
response.status, 404,
"ref starting with - should be rejected"
);
Ok(())
}
#[tokio::test]
async fn commits_page_carries_ref_select_form() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("commits-ref-form");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// Create a tag for testing tag selection marking.
let work = scratch.path("work");
assert_ok(&git(&work, &[], &["tag", "v1.0"]), "tagging");
assert_ok(
&git(
&work,
&[],
&[
"push",
"-q",
"--tags",
server
.repo_root()
.join("alice")
.join("test-repo.git")
.to_str()
.unwrap(),
],
),
"pushing tags",
);
// View commits at main.
let response = alice.get("/~alice/test-repo/commits/main");
assert_eq!(response.status, 200, "commits page");
let body = &response.body;
// Should have a form with action pointing to /commits/.
assert!(
body.contains(r#"action="/~alice/test-repo/commits/""#),
"should have a form targeting /commits/, got:\n{body}"
);
// Should have a select named "ref".
assert!(
body.contains(r#"name="ref""#),
"should have a select named 'ref', got:\n{body}"
);
// Should have branches listed.
assert!(
body.contains(r#"<optgroup label="Branches">"#),
"should have a Branches optgroup, got:\n{body}"
);
assert!(
body.contains(r#"value="main""#),
"should list the main branch, got:\n{body}"
);
// Should have tags listed.
assert!(
body.contains(r#"<optgroup label="Tags">"#),
"should have a Tags optgroup, got:\n{body}"
);
assert!(
body.contains(r#"value="v1.0""#),
"should list the v1.0 tag, got:\n{body}"
);
// Should have a submit button.
assert!(
body.contains(r#"type="submit""#),
"should have a submit button, got:\n{body}"
);
// The current ref (main) should be marked as selected.
assert!(
body.contains(r#"value="main" selected"#),
"the current ref should be marked as selected, got:\n{body}"
);
// Now view at the tag - it should be selected.
let response = alice.get("/~alice/test-repo/commits/v1.0");
assert_eq!(response.status, 200, "commits at tag");
let body = &response.body;
assert!(
body.contains(r#"value="v1.0" selected"#),
"the tag should be marked as selected when viewing at it, got:\n{body}"
);
Ok(())
}