tests/polish.rs 8.5 KiB 284 lines Raw
1 //! Polish suite (m5/docker, f0rge issue #11):2 //! CSS budget, serve 404s, empty-forge build, entrypoint fail-loudness.3 //!4 //! No docker daemon needed: the entrypoint is tested through flag parsing5 //! (`hu13 build --branch <bad>` exits non-zero, naming the branch) plus a6 //! `bash -n` syntax check of `entrypoint.sh` itself.7 8 use std::sync::atomic::{AtomicU64, Ordering};9 10 static SEQ: AtomicU64 = AtomicU64::new(0);11 12 use std::io::{BufRead, BufReader, Read, Write};13 use std::net::TcpStream;14 use std::path::{Path, PathBuf};15 use std::process::{Child, Command, Stdio};16 use std::time::Duration;17 18 fn bin() -> &'static str {19     env!("CARGO_BIN_EXE_hu13")20 }21 22 fn manifest() -> PathBuf {23     PathBuf::from(env!("CARGO_MANIFEST_DIR"))24 }25 26 fn fresh(prefix: &str) -> PathBuf {27     let dir = std::env::temp_dir().join(format!(28         "hu13-polish-{prefix}-{}-{}",29         std::process::id(),30         std::time::SystemTime::now()31             .duration_since(std::time::UNIX_EPOCH)32             .map(|d| d.as_nanos())33             .unwrap_or(0)34             .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))35     ));36     std::fs::create_dir_all(&dir).unwrap();37     dir38 }39 40 fn git(repo: &Path, args: &[&str]) {41     let st = Command::new("git")42         .arg("-C")43         .arg(repo)44         .args(args)45         .status()46         .expect("spawn git");47     assert!(st.success(), "git {args:?} failed");48 }49 50 /// Minimal repo: 1 commit, NO `.forge/` at all.51 fn bare_repo() -> PathBuf {52     let dir = fresh("repo");53     git(&dir, &["init", "-b", "main"]);54     git(&dir, &["config", "user.name", "hu13-test"]);55     git(&dir, &["config", "user.email", "[email protected]"]);56     git(&dir, &["config", "commit.gpgsign", "false"]);57     std::fs::write(dir.join("README.md"), "# polish fixture\n").unwrap();58     std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();59     git(&dir, &["add", "-A"]);60     git(&dir, &["commit", "-m", "init"]);61     assert!(!dir.join(".forge").exists());62     dir63 }64 65 struct Server {66     child: Child,67     addr: String,68 }69 70 impl Drop for Server {71     fn drop(&mut self) {72         self.child.kill().ok();73         self.child.wait().ok();74     }75 }76 77 fn start_server(www: &Path, inbox: &Path) -> Server {78     let mut child = Command::new(bin())79         .arg("serve")80         .arg("--www")81         .arg(www)82         .arg("--inbox-dir")83         .arg(inbox)84         .arg("--listen")85         .arg("127.0.0.1:0")86         .stdout(Stdio::piped())87         .stderr(Stdio::piped())88         .spawn()89         .expect("spawn hu13 serve");90     let stdout = child.stdout.take().expect("piped stdout");91     let mut lines = BufReader::new(stdout).lines();92     let mut addr = String::new();93     for _ in 0..50 {94         match lines.next() {95             Some(Ok(line)) if line.contains("listening on ") => {96                 addr = line97                     .rsplit("listening on ")98                     .next()99                     .unwrap_or("")100                     .trim()101                     .to_string();102                 break;103             }104             Some(Ok(_)) => continue,105             _ => break,106         }107     }108     assert!(!addr.is_empty(), "serve printed no `listening on` line");109     std::thread::sleep(Duration::from_millis(100));110     Server { child, addr }111 }112 113 fn transact(addr: &str, req: &[u8]) -> String {114     let mut s = TcpStream::connect(addr).expect("connect");115     s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();116     s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();117     s.write_all(req).expect("write");118     let mut out = Vec::new();119     s.read_to_end(&mut out).expect("read");120     String::from_utf8_lossy(&out).into_owned()121 }122 123 fn status(resp: &str) -> &str {124     resp.split("\r\n").next().unwrap_or("")125 }126 127 #[test]128 fn css_budget_under_15k() {129     let css = manifest().join("static").join("style.css");130     let len = std::fs::metadata(&css).expect("style.css exists").len();131     assert!(132         len < 32768,133         "static/style.css is {len} bytes, budget is 32768 (see gl0w.md)"134     );135 }136 137 #[test]138 fn unknown_path_is_404_html_for_get_and_post() {139     let www = fresh("www");140     std::fs::write(www.join("index.html"), "<!DOCTYPE html><p>hi</p>").unwrap();141     let inbox = fresh("inbox");142     let srv = start_server(&www, &inbox);143 144     let get = transact(145         &srv.addr,146         b"GET /no-such-page.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",147     );148     assert!(149         status(&get).contains("404"),150         "GET unknown -> 404, got: {get}"151     );152     assert!(get.contains("text/html"), "404 body is html, got: {get}");153     assert!(154         get.contains("X-Content-Type-Options: nosniff"),155         "404 carries nosniff"156     );157 158     // POST to an unknown (non-inbox) path is 404, not 405 and not 200.159     let body = "a=b";160     let post = transact(161         &srv.addr,162         format!(163             "POST /no-such-page.html HTTP/1.1\r\nHost: x\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",164             body.len()165         )166         .as_bytes(),167     );168     assert!(169         status(&post).contains("404"),170         "POST unknown -> 404, got: {post}"171     );172     assert!(post.contains("text/html"), "404 body is html");173 174     // A KNOWN file with the wrong method is still 405 (no regression).175     let put = transact(176         &srv.addr,177         b"PUT /index.html HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",178     );179     assert!(180         status(&put).contains("405"),181         "wrong method on real file -> 405, got: {put}"182     );183 }184 185 #[test]186 fn empty_forge_build_all_tabs_none_yet() {187     let repo = bare_repo();188     let out = fresh("out");189     let build = Command::new(bin())190         .arg("build")191         .arg(&repo)192         .arg("-o")193         .arg(&out)194         .output()195         .expect("spawn hu13 build");196     assert!(197         build.status.success(),198         "no-forge build exits 0: {}",199         String::from_utf8_lossy(&build.stderr)200     );201     for (page, marker) in [202         ("issues/index.html", "No issues yet"),203         ("pulls/index.html", "No pull requests yet"),204         ("tasks/index.html", "No tasks yet"),205         ("ci/index.html", "No CI runs yet"),206         ("releases/index.html", "No releases yet"),207         ("wiki/index.html", "No wiki pages yet"),208     ] {209         let html = std::fs::read_to_string(out.join(page)).expect(page);210         assert!(html.contains(marker), "{page} says {marker:?}");211     }212 }213 214 #[test]215 fn bad_branch_fails_loud_naming_branch() {216     let repo = bare_repo();217     let out = fresh("out-bad");218     // Flag path (what entrypoint.sh passes from $HU13_BRANCH).219     let build = Command::new(bin())220         .arg("build")221         .arg(&repo)222         .arg("-o")223         .arg(&out)224         .arg("--branch")225         .arg("does-not-exist")226         .output()227         .expect("spawn hu13 build");228     assert!(!build.status.success(), "bad branch exits non-zero");229     let err = String::from_utf8_lossy(&build.stderr).into_owned();230     assert!(231         err.contains("does-not-exist"),232         "stderr names the bad branch, got: {err}"233     );234     // Env path (HU13_BRANCH fallback inside gitx::resolve_branch).235     let out2 = fresh("out-bad-env");236     let build2 = Command::new(bin())237         .arg("build")238         .arg(&repo)239         .arg("-o")240         .arg(&out2)241         .env("HU13_BRANCH", "also-missing")242         .output()243         .expect("spawn hu13 build");244     assert!(!build2.status.success(), "bad HU13_BRANCH exits non-zero");245     let err2 = String::from_utf8_lossy(&build2.stderr).into_owned();246     assert!(247         err2.contains("also-missing"),248         "stderr names the bad env branch, got: {err2}"249     );250     // Missing repo dir is loud too.251     let out3 = fresh("out-bad-repo");252     let build3 = Command::new(bin())253         .arg("build")254         .arg("/does/not/exist-at-all")255         .arg("-o")256         .arg(&out3)257         .output()258         .expect("spawn hu13 build");259     assert!(!build3.status.success(), "missing repo exits non-zero");260 }261 262 #[test]263 fn entrypoint_syntax_checks() {264     let ep = manifest().join("entrypoint.sh");265     assert!(ep.exists(), "entrypoint.sh exists");266     let st = Command::new("bash")267         .arg("-n")268         .arg(&ep)269         .status()270         .expect("spawn bash -n");271     assert!(st.success(), "entrypoint.sh parses");272     let text = std::fs::read_to_string(&ep).expect("read entrypoint");273     for needle in [274         "set -eu",275         "hu13 build",276         "HU13_BRANCH",277         "HU13_CLONE_URL",278         "exec hu13 serve",279         "0.0.0.0:39313",280         "safe.directory",281     ] {282         assert!(text.contains(needle), "entrypoint mentions {needle}");283     }284 }