//! The tree browser: a repository's files at a given ref, walked directory
//! by directory.
//!
//! As with the landing and refs tests, the content is put there with a real
//! `git` pushed straight into the bare repository the server created.
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 tree 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.
fn git(dir: &Path, args: &[&str]) -> Output {
Command::new("git")
.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")
.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 small directory tree on
/// `main`, a branch whose name contains a slash, and an annotated tag, then
/// returns the abbreviated hash of `main`'s tip.
fn populate(server: &Server, scratch: &Scratch, owner: &str, slug: &str) -> String {
let bare = server.repo_root().join(owner).join(format!("{slug}.git"));
let work = scratch.path("work");
fs::create_dir_all(work.join("src/deep")).expect("the directories should be creatable");
fs::create_dir_all(work.join("docs")).expect("the directories should be creatable");
assert_ok(&git(&work, &["init", "-q", "-b", "main"]), "initialising");
fs::write(work.join("README.md"), "# Notes\n").expect("writing a file");
fs::write(work.join("notes.txt"), "plain notes\n").expect("writing a file");
fs::write(work.join("docs/guide.md"), "# Guide\n").expect("writing a file");
// A markdown file with relative links to verify they get rewritten to tree URLs
fs::write(
work.join("docs/index.md"),
"# Index\n\nSee [the guide](./guide.md) and [the readme](../README.md).\n",
)
.expect("writing a file");
fs::write(work.join("src/lib.rs"), "pub fn lib() {}\n").expect("writing a file");
fs::write(work.join("src/main.rs"), "fn main() {}\n").expect("writing a file");
fs::write(work.join("src/deep/nested.txt"), "deep\n").expect("writing a file");
assert_ok(&git(&work, &["add", "-A"]), "staging");
assert_ok(
&git(&work, &["commit", "-q", "-m", "Root commit"]),
"committing",
);
// A branch whose name holds a slash, so the address has to keep it one
// ref rather than read it as a directory path.
assert_ok(
&git(&work, &["checkout", "-q", "-b", "feature/x"]),
"branching",
);
fs::create_dir_all(work.join("pkg")).expect("the directory should be creatable");
fs::write(work.join("pkg/tool.py"), "print('tool')\n").expect("writing a file");
assert_ok(&git(&work, &["add", "-A"]), "staging");
assert_ok(
&git(&work, &["commit", "-q", "-m", "Add the tool"]),
"committing",
);
assert_ok(
&git(&work, &["checkout", "-q", "main"]),
"returning to main",
);
assert_ok(
&git(&work, &["tag", "-a", "v1", "-m", "First release"]),
"tagging",
);
assert_ok(
&git(&work, &["push", "-q", "--all", bare.to_str().unwrap()]),
"pushing the branches",
);
assert_ok(
&git(&work, &["push", "-q", bare.to_str().unwrap(), "--tags"]),
"pushing the tags",
);
let output = git(&work, &["rev-parse", "--short", "HEAD"]);
assert_ok(&output, "reading the head hash");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
/// The repository tab bar out of a page's body.
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_root_listing_shows_directories_then_files() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("tree-root");
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/tree/main");
assert_eq!(response.status, 200, "tree page");
let body = &response.body;
// Directories are suffixed with a slash; every entry leads somewhere -
// a directory into itself, a file onto its own page.
for directory in ["docs", "src"] {
assert!(
body.contains(&format!(
"href=\"/~alice/test-repo/tree/main/{directory}\">{directory}/</a>"
)),
"the directory `{directory}` should be linked, got:\n{body}"
);
}
for file in ["README.md", "notes.txt"] {
assert!(
body.contains(&format!(
"href=\"/~alice/test-repo/tree/main/{file}\">{file}</a>"
)),
"the file `{file}` should lead to its own page, got:\n{body}"
);
}
// Directories first, which alphabetical order would not give: `docs`
// sorts after `README.md` byte for byte.
let docs = body.find(">docs/</a>").expect("docs should be listed");
let readme = body
.find(">README.md</a>")
.expect("README.md should be listed");
assert!(
docs < readme,
"directories should come before files, got:\n{body}"
);
// The breadcrumb at the root names the ref, and the tab bar marks the
// page as the repository's browse page.
assert!(
body.contains(
"<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/main\">main</a></p>"
),
"the breadcrumb at the root, got:\n{body}"
);
let bar = tab_bar(body);
assert!(
bar.contains(r#"<a href="/~alice/test-repo/tree/main" aria-current="page">Browse</a>"#),
"the browse tab should be current, got:\n{bar}"
);
Ok(())
}
#[tokio::test]
async fn directories_lead_to_their_contents_and_the_breadcrumb_back() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("tree-walk");
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/tree/main/src");
assert_eq!(response.status, 200, "subdirectory");
let body = &response.body;
assert!(
body.contains("href=\"/~alice/test-repo/tree/main/src/deep\">deep/</a>"),
"a subdirectory of the subdirectory, got:\n{body}"
);
assert!(
body.contains("href=\"/~alice/test-repo/tree/main/src/lib.rs\">lib.rs</a>"),
"a file, got:\n{body}"
);
assert!(
!body.contains(">README.md</a>"),
"the parent's files are not in this directory, got:\n{body}"
);
assert!(
body.contains(
"<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/main\">main</a> / \
<span class=\"current\">src</span></p>"
),
"the breadcrumb should lead back to the root, got:\n{body}"
);
let response = alice.get("/~alice/test-repo/tree/main/src/deep");
assert_eq!(response.status, 200, "nested directory");
let body = &response.body;
assert!(
body.contains("href=\"/~alice/test-repo/tree/main/src/deep/nested.txt\">nested.txt</a>"),
"the nested file, now leading to its own page, got:\n{body}"
);
assert!(
body.contains(
"<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/main\">main</a> / \
<a href=\"/~alice/test-repo/tree/main/src\">src</a> / \
<span class=\"current\">deep</span></p>"
),
"every step of the path should lead back, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn a_ref_whose_name_holds_a_slash_is_one_ref_not_a_path() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("tree-slashy");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// The branch as a whole, not `feature` with `x` beneath it.
let response = alice.get("/~alice/test-repo/tree/feature/x");
assert_eq!(response.status, 200, "slashy ref");
let body = &response.body;
assert!(
body.contains(
"<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/feature/x\">feature/x</a></p>"
),
"the ref should be shown whole, got:\n{body}"
);
assert!(
body.contains("href=\"/~alice/test-repo/tree/feature/x/pkg\">pkg/</a>"),
"the branch's directory, got:\n{body}"
);
// And a directory within it: the ref stays whole, the rest is path.
let response = alice.get("/~alice/test-repo/tree/feature/x/pkg");
assert_eq!(response.status, 200, "slashy ref subdirectory");
let body = &response.body;
assert!(
body.contains("href=\"/~alice/test-repo/tree/feature/x/pkg/tool.py\">tool.py</a>"),
"the file in the branch's directory, got:\n{body}"
);
assert!(
body.contains(
"<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/feature/x\">feature/x</a> / \
<span class=\"current\">pkg</span></p>"
),
"the breadcrumb should keep the ref whole, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn a_tag_and_a_commit_hash_can_be_browsed_too() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("tree-revs");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
let head = populate(&server, &scratch, "alice", "test-repo");
// An annotated tag is peeled to the commit it names.
let response = alice.get("/~alice/test-repo/tree/v1");
assert_eq!(response.status, 200, "tag tree");
assert!(
response.body.contains(">README.md</a>"),
"the tag should show the tree of its commit, got:\n{}",
response.body
);
// A raw commit hash is not a ref at all, but git knows it, and the
// address says what to show: that commit's tree.
let response = alice.get(&format!("/~alice/test-repo/tree/{head}"));
assert_eq!(response.status, 200, "hash tree");
assert!(
response.body.contains(">README.md</a>"),
"the hash should show that commit's tree, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn the_browse_tab_and_a_bare_tree_address_use_the_default_branch() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("tree-default");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// The tab on any of the repository's pages leads to the default branch.
let landing = alice.get("/~alice/test-repo");
assert_eq!(landing.status, 200, "landing page");
assert!(
landing
.body
.contains("<a href=\"/~alice/test-repo/tree/main\">Browse</a>"),
"the browse tab should address the default branch, got:\n{}",
landing.body
);
// The address without a ref settles on the default branch rather than
// refusing to say what a repository opens on.
let response = alice.get("/~alice/test-repo/tree/");
assert_eq!(response.status, 303, "empty tail");
assert_eq!(
response.location.as_deref(),
Some("/~alice/test-repo/tree/main"),
"the empty tail should redirect to the default branch"
);
let response = alice.get("/~alice/test-repo/tree");
assert_eq!(response.status, 303, "bare tree address");
assert_eq!(
response.location.as_deref(),
Some("/~alice/test-repo/tree/main"),
"the bare tree address should redirect to the default branch"
);
Ok(())
}
#[tokio::test]
async fn a_trailing_slash_says_nothing_more_than_no_slash_does() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("tree-trailing-slash");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// The same page with and without the slash, at a revision and at a path
// beneath it: one trailing slash is trimmed rather than naming anything
// of its own.
let response = alice.get("/~alice/test-repo/tree/main/");
assert_eq!(response.status, 200, "trailing slash on the revision");
let response = alice.get("/~alice/test-repo/tree/main/src/");
assert_eq!(response.status, 200, "trailing slash on the path");
assert!(
response
.body
.contains("href=\"/~alice/test-repo/tree/main/src/deep\">deep/</a>"),
"the listing under a slashed path should be the same one, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn an_unknown_ref_or_path_is_not_found() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("tree-unknown");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
assert_eq!(
alice.get("/~alice/test-repo/tree/nothing").status,
404,
"a ref that does not exist"
);
assert_eq!(
alice.get("/~alice/test-repo/tree/main/nothing").status,
404,
"a path that is not in the tree"
);
assert_eq!(
alice.get("/~alice/test-repo/tree/nothing/deeper").status,
404,
"neither a ref nor a revision"
);
Ok(())
}
#[tokio::test]
async fn a_private_repositorys_tree_is_not_served() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("tree-private");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "secret", "private");
populate(&server, &scratch, "alice", "secret");
assert_eq!(
Client::new(port).get("/~alice/secret/tree/main").status,
404,
"a stranger should not reach the tree"
);
let mut bob = Client::new(port);
register(&mut bob, "bob");
assert_eq!(
bob.get("/~alice/secret/tree/main").status,
404,
"another user should not reach the tree"
);
assert_eq!(
alice.get("/~alice/secret/tree/main").status,
200,
"the owner should reach the tree"
);
Ok(())
}
#[tokio::test]
async fn relative_links_in_markdown_are_rewritten_to_tree_urls() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("tree-markdown-links");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// When viewing a markdown file, relative links should be rewritten to tree URLs
let response = alice.get("/~alice/test-repo/tree/main/docs/index.md");
assert_eq!(response.status, 200, "markdown file");
let body = &response.body;
// The relative link ./guide.md should be rewritten to the tree URL
assert!(
body.contains(r#"href="/~alice/test-repo/tree/main/docs/guide.md""#),
"relative link to guide.md should be rewritten to tree URL, got:\n{body}"
);
// The relative link ../README.md should be rewritten to the tree URL
assert!(
body.contains(r#"href="/~alice/test-repo/tree/main/README.md""#),
"relative link to README.md should be rewritten to tree URL, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn an_empty_repositorys_tree_says_there_is_nothing_to_list() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
// Nothing has ever been pushed, so the default branch does not resolve
// to a commit: that is an empty repository, not a missing page.
let response = alice.get("/~alice/test-repo/tree/main");
assert_eq!(response.status, 200, "empty repository tree");
assert!(
response.body.contains("Nothing here yet."),
"an empty repository should say it has nothing to list, got:\n{}",
response.body
);
Ok(())
}