//! Helpers shared by the forge-web e2e tests. Every test file pulls this in with `mod common;`, so //! items a given test binary does not use are allowed to be dead code. //! //! The server is started with `launch`, which waits for the three listeners (HTTP, user content //! and SSH) with backoff. A connection that is refused just means "not yet". If the child exits //! first, the panic carries its captured log. A port picked with `pick_ports` is only free until //! the server binds it, so `launch` retries on fresh ports when the server reports a bind conflict. #![allow(dead_code)] use std::fs; use std::net::TcpStream; use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Output, Stdio}; use std::time::{Duration, Instant}; pub const BIN: &str = env!("CARGO_BIN_EXE_forge"); /// How long a freshly spawned server gets to answer on all three listeners. const READY_TIMEOUT: Duration = Duration::from_secs(30); /// Starts `forge serve` on fresh ports at most this many times after bind conflicts. const MAX_LAUNCH_ATTEMPTS: u32 = 5; /// The running `forge serve` child. Killed on drop, even when an assertion fails. pub struct Server(pub Child); impl Drop for Server { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } /// The three loopback ports a server listens on: the app, SSH and the raw user-content listener. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Ports { pub http: u16, pub ssh: u16, pub content: u16, } /// A port the kernel handed out for a moment. It is free only until something binds it. pub fn free_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0") .expect("bind free port") .local_addr() .expect("local addr") .port() } /// Three distinct free ports. pub fn pick_ports() -> Ports { let mut ports: Vec = Vec::new(); while ports.len() < 3 { let port = free_port(); if !ports.contains(&port) { ports.push(port); } } Ports { http: ports[0], ssh: ports[1], content: ports[2], } } pub fn tool_on_path(name: &str) -> bool { Command::new(name) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .is_ok() } /// Writes `dir/forge.toml` for `ports` and returns its path. pub fn write_config(dir: &Path, ports: Ports) -> PathBuf { write_config_with(dir, ports, "") } /// Like `write_config`, with `extra` TOML appended after the defaults (for example a `[webhooks]` /// table). `extra` must not repeat a key or table the defaults already set. pub fn write_config_with(dir: &Path, ports: Ports, extra: &str) -> PathBuf { let d = dir.display(); let Ports { http: http_port, ssh: ssh_port, content: content_port, } = ports; let text = format!( "database = \"{d}/forge.db\"\n\ data_dir = \"{d}\"\n\ listen = \"127.0.0.1:{http_port}\"\n\ base_url = \"http://127.0.0.1:{http_port}\"\n\ user_content_base_url = \"http://127.0.0.1:{content_port}\"\n\ user_content_listen = \"127.0.0.1:{content_port}\"\n\ ssh_listen = \"127.0.0.1:{ssh_port}\"\n\ ssh_public_host = \"127.0.0.1\"\n\ ssh_public_port = {ssh_port}\n\ registration = \"closed\"\n{extra}" ); let path = dir.join("forge.toml"); fs::write(&path, text).expect("write config"); path } pub fn describe(out: &Output) -> String { format!( "status: {}\nstdout: {}\nstderr: {}", out.status, String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ) } /// Starts `forge serve` on `ports` and returns once all three listeners answer. The config is /// written to `dir/forge.toml` and the log to `dir/serve.log`. /// /// If the server exits because one of the ports was taken in the meantime, it is started again on /// fresh ports. The ports in use are returned, so callers must build their clients from them. pub async fn launch(dir: &Path, ports: Ports) -> (Server, Ports) { launch_with(dir, ports, "").await } /// `launch` with extra TOML appended to the config (see `write_config_with`). pub async fn launch_with(dir: &Path, ports: Ports, extra: &str) -> (Server, Ports) { let log_path = dir.join("serve.log"); let mut ports = ports; for attempt in 1..=MAX_LAUNCH_ATTEMPTS { let config = write_config_with(dir, ports, extra); let mut server = spawn(&config, &log_path); match wait_ready(ports, &mut server, &log_path).await { Ok(()) => return (server, ports), Err(status) => { let log = read_log(&log_path); if attempt < MAX_LAUNCH_ATTEMPTS && bind_conflict(&log) { ports = pick_ports(); continue; } panic!("forge exited early ({status}):\n{log}"); } } } unreachable!("the last attempt either returns or panics") } /// Starts the server again on the exact `ports` of an earlier run, for restart tests. Unlike /// `launch` this does not retry, because the caller's clients already use these ports. pub async fn relaunch(config: &Path, log_path: &Path, ports: Ports) -> Server { let mut server = spawn(config, log_path); match wait_ready(ports, &mut server, log_path).await { Ok(()) => server, Err(status) => panic!( "forge exited early on restart ({status}):\n{}", read_log(log_path) ), } } fn spawn(config: &Path, log_path: &Path) -> Server { let log = fs::File::create(log_path).expect("log file"); let child = Command::new(BIN) .arg("--config") .arg(config) .arg("serve") .stdin(Stdio::null()) .stdout(Stdio::from(log.try_clone().expect("clone log"))) .stderr(Stdio::from(log)) .spawn() .expect("spawn forge serve"); Server(child) } /// Polls the three listeners with backoff until they all answer. Returns the exit status if the /// child dies first, and panics with the log if the deadline passes. async fn wait_ready(ports: Ports, server: &mut Server, log: &Path) -> Result<(), ExitStatus> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build() .expect("probe client"); let app = format!("http://127.0.0.1:{}/healthz", ports.http); let raw = format!("http://127.0.0.1:{}/healthz", ports.content); let deadline = Instant::now() + READY_TIMEOUT; let mut delay = Duration::from_millis(10); loop { if let Some(status) = server.0.try_wait().expect("poll forge-web") { return Err(status); } if probe(&client, &app).await && probe(&client, &raw).await && ssh_open(ports.ssh) { return Ok(()); } if Instant::now() >= deadline { panic!( "forge was not ready within {READY_TIMEOUT:?}:\n{}", read_log(log) ); } tokio::time::sleep(delay).await; delay = (delay * 2).min(Duration::from_millis(250)); } } /// True when the URL answers 200. Connection refused, timeouts and other errors all mean "not yet". async fn probe(client: &reqwest::Client, url: &str) -> bool { matches!(client.get(url).send().await, Ok(res) if res.status() == reqwest::StatusCode::OK) } fn ssh_open(port: u16) -> bool { TcpStream::connect(("127.0.0.1", port)).is_ok() } fn read_log(log: &Path) -> String { fs::read_to_string(log).unwrap_or_default() } /// Whether the server log reports that a listener could not bind because the port is taken. fn bind_conflict(log: &str) -> bool { let log = log.to_ascii_lowercase(); log.contains("address already in use") || log.contains("addrinuse") || log.contains("os error 98") || log.contains("os error 48") || log.contains("os error 10048") }