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 }