#![allow(
clippy::tests_outside_test_module,
clippy::unwrap_used,
clippy::expect_used,
clippy::panic
)]
use core::time::Duration;
use std::io::{BufRead as _, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::Instant;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
struct ChildGuard(Option<Child>);
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(mut child) = self.0.take() {
let _kill = child.kill();
let _wait = child.wait();
}
}
}
fn smoke_client() -> reqwest::blocking::Client {
reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
.expect("build reqwest client")
}
#[must_use]
fn start_server_with_env(
set_vars: &[(&str, &str)],
remove_vars: &[&str],
) -> (ChildGuard, u16, Vec<String>) {
let bin = env!("CARGO_BIN_EXE_docspec-http");
let mut cmd = Command::new(bin);
cmd.args(["--port", "0"])
.stderr(Stdio::piped())
.stdout(Stdio::null());
for (key, value) in set_vars {
cmd.env(key, value);
}
for key in remove_vars {
cmd.env_remove(key);
}
let mut child = cmd.spawn().expect("failed to start docspec-http");
let stderr = child.stderr.take().expect("stderr pipe");
let guard = ChildGuard(Some(child));
let (tx, rx) = mpsc::channel::<String>();
thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
let _send = tx.send(line);
}
});
let deadline = Instant::now()
.checked_add(STARTUP_TIMEOUT)
.expect("deadline overflow");
let mut captured_lines: Vec<String> = Vec::new();
let mut port: u16 = 0;
while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
let Ok(line) = rx.recv_timeout(remaining) else {
break;
};
captured_lines.push(line.clone());
if line.contains("listening") {
if let Some(parsed) = line
.split("127.0.0.1:")
.nth(1)
.and_then(|after_ip| {
after_ip
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse::<u16>()
.ok()
})
.filter(|&port_num| port_num != 0)
{
port = parsed;
break;
}
}
}
let captured_display = captured_lines.join("\n");
assert_ne!(
port,
0,
"server did not bind within {}s\nstderr captured:\n{captured_display}",
STARTUP_TIMEOUT.as_secs()
);
(guard, port, captured_lines)
}
#[test]
fn binary_starts_without_dsn() {
let (_guard, port, captured_lines) =
start_server_with_env(&[], &["DOCSPEC_SENTRY_DSN", "SENTRY_DSN"]);
assert!(
captured_lines
.iter()
.any(|line| line.contains("docspec-http listening")),
"expected 'docspec-http listening' in stderr\ncaptured:\n{}",
captured_lines.join("\n")
);
let client = smoke_client();
let resp = client
.get(format!("http://127.0.0.1:{port}/health"))
.send()
.expect("HTTP request");
assert_eq!(resp.status(), reqwest::StatusCode::OK);
}
#[test]
fn binary_starts_with_invalid_dsn() {
let (_guard, port, captured_lines) =
start_server_with_env(&[("SENTRY_DSN", "not-a-dsn")], &["DOCSPEC_SENTRY_DSN"]);
let captured_joined = captured_lines.join("\n");
assert!(
captured_lines
.iter()
.any(|line| line.contains("warning: invalid Sentry DSN format; Sentry disabled")),
"expected invalid-DSN warning in stderr\ncaptured:\n{captured_joined}"
);
assert!(
!captured_joined.contains("not-a-dsn"),
"DSN value must not appear in stderr (G1)\ncaptured:\n{captured_joined}"
);
let client = smoke_client();
let resp = client
.get(format!("http://127.0.0.1:{port}/health"))
.send()
.expect("HTTP request");
assert_eq!(resp.status(), reqwest::StatusCode::OK);
}
#[test]
fn binary_starts_with_placeholder_valid_dsn() {
let (_guard, port, captured_lines) = start_server_with_env(
&[("DOCSPEC_SENTRY_DSN", "https://public@127.0.0.1:9/1")],
&["SENTRY_DSN"],
);
let captured_joined = captured_lines.join("\n");
assert!(
captured_lines
.iter()
.any(|line| line.contains("docspec-http listening")),
"expected 'docspec-http listening' in stderr\ncaptured:\n{captured_joined}"
);
assert!(
!captured_joined.contains("public@127.0.0.1:9/1"),
"DSN value must not appear in stderr (G1)\ncaptured:\n{captured_joined}"
);
let client = smoke_client();
let resp = client
.post(format!("http://127.0.0.1:{port}/conversion"))
.header("Content-Type", "text/markdown")
.body("# Hello\n\nWorld")
.send()
.expect("HTTP request");
assert_eq!(resp.status(), reqwest::StatusCode::OK);
}
#[test]
fn binary_handles_sigterm_cleanly_with_sentry_active() {
let (mut guard, _port, _captured_lines) = start_server_with_env(
&[("DOCSPEC_SENTRY_DSN", "https://public@127.0.0.1:9/1")],
&["SENTRY_DSN"],
);
let pid = guard.0.as_ref().expect("child is alive").id();
Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status()
.expect("kill -TERM");
let mut child = guard.0.take().expect("child is alive");
let deadline = Instant::now()
.checked_add(Duration::from_secs(5))
.expect("deadline overflow");
loop {
if let Some(status) = child.try_wait().expect("wait for child") {
assert_eq!(
status.code(),
Some(0),
"expected exit code 0 after SIGTERM, got: {status:?}"
);
return;
}
if Instant::now() > deadline {
let _kill = child.kill();
let _wait = child.wait();
panic!("process did not exit within 5s after SIGTERM");
}
thread::sleep(Duration::from_millis(50));
}
}