use std::collections::HashMap;
use std::path::Path;
use hurl::runner::{
self, AssertResult, EntryResult, EventListener, RunnerError, RunnerOptionsBuilder, Value,
VariableSet,
};
use hurl::util::logger::{Logger, LoggerOptionsBuilder};
use hurl::util::path::ContextDir;
use hurl::util::term::{Stderr, Stdout, WriteMode};
use hurl_core::error::DisplaySourceError;
use hurl_core::parser::parse_hurl_file;
use serde_json::Value as JsonValue;
#[derive(Debug, Clone)]
pub struct AssertOutcome {
pub expr: String,
pub passed: bool,
pub detail: String,
}
#[derive(Default)]
pub struct EntryOutcome {
pub entry_index: usize,
pub superseded: bool,
pub method: String,
pub url: String,
pub status: u16,
pub status_text: String,
pub headers: Vec<(String, String)>,
pub body: String,
pub raw_body: String,
pub asserts: Vec<AssertOutcome>,
pub captures: Vec<(String, String)>,
pub duration_ms: u64,
pub setup_ms: u64,
pub wait_ms: u64,
pub download_ms: u64,
pub ok: bool,
pub error: Option<String>,
}
pub enum EntrySetup {
Bind(Vec<(String, String)>),
Skip { reason: String },
}
pub struct RunOutput {
pub entries: Vec<EntryOutcome>,
pub error: Option<String>,
pub generated: std::collections::HashMap<String, String>,
}
fn context_dir(file_root: Option<&Path>) -> ContextDir {
let current_dir = std::env::current_dir().unwrap_or_default();
let file_root = file_root.unwrap_or(¤t_dir);
ContextDir::new(¤t_dir, file_root)
}
pub fn run_hurl(
content: &str,
vars: &HashMap<String, String>,
file_root: Option<&Path>,
) -> RunOutput {
run_hurl_watching(content, vars, file_root, |_, _, _| {})
}
pub fn run_hurl_watching(
content: &str,
vars: &HashMap<String, String>,
file_root: Option<&Path>,
mut on_attempt: impl FnMut(usize, usize, RetryLimit),
) -> RunOutput {
let hurl_file = match parse_hurl_file(content) {
Ok(h) => h,
Err(e) => {
return RunOutput {
entries: vec![],
error: Some(format!("Parse error (line {}): {:?}", e.pos.line, e.kind)),
generated: Default::default(),
};
}
};
let runner_opts = RunnerOptionsBuilder::new()
.continue_on_error(true)
.context_dir(&context_dir(file_root))
.build();
let logger_opts = LoggerOptionsBuilder::new().build();
let mut variables = VariableSet::new();
for (k, v) in vars {
variables.insert(k.clone(), Value::String(v.clone()));
}
let secrets = variables.secrets();
let mut stdout = Stdout::new(WriteMode::Buffered);
let mut logger = Logger::new(&logger_opts, Stderr::new(WriteMode::Buffered), &secrets);
let reporter = AttemptReporter {
on_attempt: std::cell::RefCell::new(&mut on_attempt),
limit: hurl_file
.entries
.first()
.map(|e| entry_retry_limit(e, &variables))
.unwrap_or_default(),
};
let result = runner::run_entries(
&hurl_file.entries,
content,
None,
&runner_opts,
&variables,
&mut stdout,
Some(&reporter),
&mut logger,
);
let lines: Vec<&str> = content.lines().collect();
let mut entries = Vec::new();
let mut errors: Vec<Option<String>> = Vec::new();
for e in &result.entries {
let (outcome, entry_error) = map_entry_result(e, &lines);
entries.push(outcome);
errors.push(entry_error);
}
mark_superseded(&mut entries, |i| {
hurl_file.entries.get(i).is_some_and(entry_retries)
});
let error = entries
.iter()
.zip(&errors)
.find_map(|(e, err)| (!e.superseded).then_some(err.clone()).flatten());
RunOutput {
entries,
error,
generated: Default::default(),
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum RetryLimit {
#[default]
Unknown,
Forever,
Times(usize),
}
impl RetryLimit {
pub fn total(self) -> Option<String> {
match self {
RetryLimit::Times(n) => Some(n.to_string()),
RetryLimit::Forever => Some("∞".to_string()),
RetryLimit::Unknown => None,
}
}
}
impl From<i64> for RetryLimit {
fn from(n: i64) -> Self {
usize::try_from(n).map_or(RetryLimit::Forever, RetryLimit::Times)
}
}
fn entry_retry_limit(entry: &hurl_core::ast::Entry, vars: &VariableSet) -> RetryLimit {
use hurl_core::ast::{CountOption, ExprKind, OptionKind};
use hurl_core::types::Count;
entry
.request
.options()
.iter()
.find_map(|opt| match &opt.kind {
OptionKind::Retry(CountOption::Literal(Count::Finite(n))) => {
Some(RetryLimit::Times(*n))
}
OptionKind::Retry(CountOption::Literal(Count::Infinite)) => Some(RetryLimit::Forever),
OptionKind::Retry(CountOption::Placeholder(p)) => Some(match &p.expr.kind {
ExprKind::Variable(v) => vars
.get(&v.name)
.and_then(|v| v.value().to_string().trim().parse::<i64>().ok())
.map_or(RetryLimit::Unknown, RetryLimit::from),
ExprKind::Function(_) => RetryLimit::Unknown,
}),
_ => None,
})
.unwrap_or_default()
}
struct AttemptReporter<'a> {
on_attempt: std::cell::RefCell<&'a mut dyn FnMut(usize, usize, RetryLimit)>,
limit: RetryLimit,
}
impl EventListener for AttemptReporter<'_> {
fn on_entry_running(
&self,
current: hurl_core::types::Index,
_last: hurl_core::types::Index,
retry_count: usize,
) {
(self.on_attempt.borrow_mut())(current.to_zero_based(), retry_count, self.limit);
}
}
fn entry_retries(entry: &hurl_core::ast::Entry) -> bool {
use hurl_core::ast::OptionKind;
entry
.request
.options()
.iter()
.any(|opt| matches!(opt.kind, OptionKind::Retry(_)))
}
fn mark_superseded(outcomes: &mut [EntryOutcome], retries: impl Fn(usize) -> bool) {
for i in 0..outcomes.len().saturating_sub(1) {
let index = outcomes[i].entry_index;
if outcomes[i + 1].entry_index == index && retries(index) {
outcomes[i].superseded = true;
}
}
}
fn entry_variable_defaults(entry: &hurl_core::ast::Entry) -> Vec<(String, String)> {
use hurl_core::ast::OptionKind;
entry
.request
.options()
.iter()
.filter_map(|opt| match &opt.kind {
OptionKind::Variable(def) => Some((def.name.clone(), def.value.to_string())),
_ => None,
})
.collect()
}
pub fn run_hurl_streaming_with(
content: &str,
vars: &HashMap<String, String>,
file_root: Option<&Path>,
mut before_entry: impl FnMut(usize, &HashMap<String, String>) -> EntrySetup,
mut on_entry: impl FnMut(&EntryOutcome),
mut on_attempt: impl FnMut(usize, usize, RetryLimit),
) -> RunOutput {
let hurl_file = match parse_hurl_file(content) {
Ok(h) => h,
Err(e) => {
return RunOutput {
entries: vec![],
error: Some(format!("Parse error (line {}): {:?}", e.pos.line, e.kind)),
generated: Default::default(),
};
}
};
let ctx_dir = context_dir(file_root);
let logger_opts = LoggerOptionsBuilder::new().build();
let mut variables = VariableSet::new();
for (k, v) in vars {
variables.insert(k.clone(), Value::String(v.clone()));
}
let lines: Vec<&str> = content.lines().collect();
let mut error: Option<String> = None;
let mut entries = Vec::new();
let total = hurl_file.entries.len();
for i in 1..=total {
let mut known: HashMap<String, String> = variables
.iter()
.map(|(k, v)| (k.clone(), v.value().to_string()))
.collect();
for (name, value) in entry_variable_defaults(&hurl_file.entries[i - 1]) {
known.entry(name).or_insert(value);
}
match before_entry(i - 1, &known) {
EntrySetup::Bind(bindings) => {
for (k, v) in bindings {
variables.insert(k, Value::String(v));
}
}
EntrySetup::Skip { reason } => {
let req = &hurl_file.entries[i - 1].request;
let outcome = EntryOutcome {
entry_index: i - 1,
method: req.method.to_string(),
url: req.url.to_string(),
ok: false,
error: Some(reason.clone()),
..Default::default()
};
if error.is_none() {
error = Some(reason);
}
on_entry(&outcome);
entries.push(outcome);
continue;
}
}
let runner_opts = RunnerOptionsBuilder::new()
.continue_on_error(true)
.from_entry(Some(i))
.to_entry(Some(i))
.context_dir(&ctx_dir)
.build();
let secrets = variables.secrets();
let mut stdout = Stdout::new(WriteMode::Buffered);
let mut logger = Logger::new(&logger_opts, Stderr::new(WriteMode::Buffered), &secrets);
let reporter = AttemptReporter {
on_attempt: std::cell::RefCell::new(&mut on_attempt),
limit: entry_retry_limit(&hurl_file.entries[i - 1], &variables),
};
let result = runner::run_entries(
&hurl_file.entries,
content,
None,
&runner_opts,
&variables,
&mut stdout,
Some(&reporter),
&mut logger,
);
variables = result.variables;
let mut window: Vec<EntryOutcome> = Vec::new();
let mut window_errors: Vec<Option<String>> = Vec::new();
for e in &result.entries {
let (outcome, entry_error) = map_entry_result(e, &lines);
window.push(outcome);
window_errors.push(entry_error);
}
let retried = entry_retries(&hurl_file.entries[i - 1]);
mark_superseded(&mut window, |_| retried);
for (outcome, entry_error) in window.into_iter().zip(window_errors) {
if error.is_none() && !outcome.superseded {
error = entry_error;
}
on_entry(&outcome);
entries.push(outcome);
}
}
RunOutput {
entries,
error,
generated: Default::default(),
}
}
fn map_entry_result(e: &EntryResult, lines: &[&str]) -> (EntryOutcome, Option<String>) {
let (method, url) = e
.calls
.last()
.map(|c| (c.request.method.clone(), c.request.url.to_string()))
.unwrap_or_default();
let (status, headers, body, raw_body) = match e.calls.last() {
Some(call) => {
let r = &call.response;
let hdrs = r
.headers
.iter()
.map(|h| (h.name.clone(), h.value.clone()))
.collect();
let bytes = r.uncompress_body().unwrap_or_else(|_| r.body.clone());
let raw = String::from_utf8_lossy(&bytes).to_string();
let body = serde_json::from_str::<JsonValue>(&raw)
.map(|v| serde_json::to_string_pretty(&v).unwrap_or_else(|_| raw.clone()))
.unwrap_or_else(|_| raw.clone());
(r.status as u16, hdrs, body, raw)
}
None => (0, Vec::new(), String::new(), String::new()),
};
let mut asserts = Vec::new();
for a in &e.asserts {
if let AssertResult::ImplicitStatus {
actual, expected, ..
} = a
{
let failed = a.to_runner_error().is_some();
asserts.push(AssertOutcome {
expr: format!("status == {expected}"),
passed: !failed,
detail: if failed {
format!("got {actual}")
} else {
String::new()
},
});
}
}
for a in &e.asserts {
if !matches!(a, AssertResult::Explicit { .. }) {
continue;
}
let err = a.to_runner_error();
let expr = lines
.get(a.line().saturating_sub(1))
.map(|l| l.trim().to_string())
.unwrap_or_default();
let detail = err.as_ref().map(assert_detail).unwrap_or_default();
asserts.push(AssertOutcome {
expr,
passed: err.is_none(),
detail,
});
}
let captures = e
.captures
.iter()
.map(|c| (c.name.clone(), c.value.to_string()))
.collect();
let status_mismatch = e.asserts.iter().find_map(|a| match a {
AssertResult::ImplicitStatus {
actual, expected, ..
} if a.to_runner_error().is_some() => Some((*actual, *expected)),
_ => None,
});
let entry_error = if let Some((actual, expected)) = status_mismatch {
let reason = reason(actual as u16);
let actual_txt = if reason.is_empty() {
format!("{actual}")
} else {
format!("{actual} {reason}")
};
Some(format!(
"Expected status {expected} but got {actual_txt} ({method} {url})"
))
} else {
e.errors.first().map(|er| render_error(er, lines))
};
let (setup_ms, wait_ms, download_ms) = e.calls.iter().fold((0, 0, 0), |(s, w, d), c| {
let t = &c.timings;
let pre = t.pre_transfer;
let start = t.start_transfer.max(pre);
let total = t.total.max(start);
(
s + pre.as_millis() as u64,
w + (start - pre).as_millis() as u64,
d + (total - start).as_millis() as u64,
)
});
(
EntryOutcome {
entry_index: e.entry_index.to_zero_based(),
superseded: false,
method,
url,
status,
status_text: reason(status).to_string(),
headers,
body,
raw_body,
asserts,
captures,
duration_ms: e.transfer_duration.as_millis() as u64,
setup_ms,
wait_ms,
download_ms,
ok: e.errors.is_empty(),
error: entry_error.clone(),
},
entry_error,
)
}
fn assert_detail(e: &RunnerError) -> String {
e.description()
}
fn render_error(e: &RunnerError, lines: &[&str]) -> String {
let desc = e.description();
let line = e.source_info().start.line;
match lines.get(line.saturating_sub(1)) {
Some(l) if !l.trim().is_empty() => format!("{desc}: {}", l.trim()),
_ => desc,
}
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
422 => "Unprocessable Entity",
429 => "Too Many Requests",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
_ => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TempPath(std::path::PathBuf);
impl Drop for TempPath {
fn drop(&mut self) {
if self.0.is_dir() {
std::fs::remove_dir_all(&self.0).ok();
} else {
std::fs::remove_file(&self.0).ok();
}
}
}
impl std::ops::Deref for TempPath {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl TempPath {
fn as_path(&self) -> &std::path::Path {
&self.0
}
}
fn temp_path(dir: &std::path::Path, prefix: &str) -> TempPath {
TempPath(dir.join(format!("{prefix}_{}", uuid::Uuid::new_v4())))
}
fn one_shot_server(status: u16, reason: &str) -> u16 {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let reason = reason.to_string();
std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
let body = "{\"ok\":true}";
let resp = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes());
let _ = sock.flush();
}
});
port
}
fn polling_server(pending: usize) -> u16 {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
let mut seen = 0;
while let Ok((mut sock, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
seen += 1;
let state = if seen > pending { "Matched" } else { "Pending" };
let body = format!("{{\"result\":\"{state}\",\"attempts\":4}}");
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes());
let _ = sock.flush();
}
});
port
}
#[test]
fn a_poll_that_succeeds_on_the_third_go_is_a_pass() {
let port = polling_server(2);
let content = format!(
"GET http://127.0.0.1:{port}/\n[Options]\nretry: 5\nretry-interval: 20\nHTTP 200\n\
[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
let out = run_hurl(&content, &HashMap::new(), None);
assert_eq!(out.entries.len(), 3, "every attempt is still reported");
let surviving: Vec<&EntryOutcome> = out.entries.iter().filter(|e| !e.superseded).collect();
assert_eq!(surviving.len(), 1, "one request, one outcome that counts");
assert!(surviving[0].ok, "{:?}", surviving[0].error);
assert!(
out.error.is_none(),
"the run reported {:?} for a poll that succeeded",
out.error
);
}
#[test]
fn each_attempt_is_reported_while_the_poll_is_still_running() {
let port = polling_server(2);
let content = format!(
"GET http://127.0.0.1:{port}/\n[Options]\nretry: 5\nretry-interval: 20\nHTTP 200\n\
[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
let mut seen: Vec<(usize, usize, RetryLimit)> = Vec::new();
let out = run_hurl_watching(&content, &HashMap::new(), None, |i, attempt, limit| {
seen.push((i, attempt, limit))
});
assert!(out.entries.iter().any(|e| e.ok), "the poll did succeed");
assert_eq!(
seen,
vec![
(0, 0, RetryLimit::Times(5)),
(0, 1, RetryLimit::Times(5)),
(0, 2, RetryLimit::Times(5))
],
"the first send plus the two retries, each with the stated limit"
);
}
#[test]
fn a_request_that_is_not_retried_reports_a_single_first_attempt() {
let port = polling_server(0);
let content = format!("GET http://127.0.0.1:{port}/\nHTTP 200\n");
let mut seen: Vec<(usize, usize, RetryLimit)> = Vec::new();
let _ = run_hurl_watching(&content, &HashMap::new(), None, |i, attempt, limit| {
seen.push((i, attempt, limit))
});
assert_eq!(seen, vec![(0, 0, RetryLimit::Unknown)]);
}
#[test]
fn a_placeholder_limit_is_resolved_from_the_variables() {
let port = polling_server(2);
let content = format!(
"GET http://127.0.0.1:{port}/\nHTTP 200\n[Captures]\n\
max_attempts: jsonpath \"$.attempts\"\n\n\
GET http://127.0.0.1:{port}/\n[Options]\nretry: {{{{max_attempts}}}}\n\
retry-interval: 20\nHTTP 200\n[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
let mut limits: Vec<(usize, RetryLimit)> = Vec::new();
let out = run_hurl_streaming_with(
&content,
&HashMap::new(),
None,
|_, _| EntrySetup::Bind(Vec::new()),
|_| {},
|i, _, limit| limits.push((i, limit)),
);
assert!(
out.entries.iter().any(|e| e.entry_index == 1 && e.ok),
"the poll did succeed: {:?}",
out.error
);
assert!(
limits
.iter()
.filter(|(i, _)| *i == 1)
.all(|(_, l)| *l == RetryLimit::Times(4)),
"the placeholder should have been looked up, got {limits:?}"
);
}
#[test]
fn a_placeholder_limit_that_resolves_to_nothing_has_no_total() {
let port = polling_server(1);
let content = format!(
"GET http://127.0.0.1:{port}/\n[Options]\nretry: {{{{max_attempts}}}}\n\
retry-interval: 20\nHTTP 200\n[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
let mut limits: Vec<RetryLimit> = Vec::new();
let _ = run_hurl_watching(&content, &HashMap::new(), None, |_, _, limit| {
limits.push(limit)
});
assert!(
limits.iter().all(|l| *l == RetryLimit::Unknown),
"got {limits:?}"
);
}
#[test]
fn a_limit_hurl_cannot_resolve_stops_the_entry_rather_than_standing_in_for_a_number() {
let port = polling_server(99);
let content = format!(
"GET http://127.0.0.1:{port}/\n[Options]\nretry: {{{{n}}}}\nretry-interval: 20\n\
HTTP 200\n[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
for (label, vars, expected) in [
("undefined", HashMap::new(), "Undefined variable"),
(
"text that looks like a number",
HashMap::from([("n".to_string(), "3".to_string())]),
"Invalid expression type",
),
] {
let mut retries = 0;
let out = run_hurl_watching(&content, &vars, None, |_, attempt, _| {
retries = retries.max(attempt)
});
assert_eq!(retries, 0, "{label}: nothing should have been retried");
assert_eq!(out.entries.len(), 1, "{label}: the request was not sent");
let error = out.error.unwrap_or_default();
assert!(
error.contains(expected),
"{label}: expected {expected:?}, got {error:?}"
);
}
}
#[test]
fn a_forever_retry_is_reported_as_forever_not_as_no_limit() {
let port = polling_server(1);
let content = format!(
"GET http://127.0.0.1:{port}/\n[Options]\nretry: -1\nretry-interval: 20\nHTTP 200\n\
[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
let mut limits: Vec<RetryLimit> = Vec::new();
let _ = run_hurl_watching(&content, &HashMap::new(), None, |_, _, limit| {
limits.push(limit)
});
assert!(
limits.iter().all(|l| *l == RetryLimit::Forever),
"retry: -1 is forever, not an unknown limit; got {limits:?}"
);
}
#[test]
fn streaming_reports_which_entry_is_being_retried() {
let port = polling_server(2);
let content = format!(
"GET http://127.0.0.1:{port}/first\nHTTP 200\n\n\
GET http://127.0.0.1:{port}/second\n[Options]\nretry: 3\nretry-interval: 20\n\
HTTP 200\n[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
let mut seen: Vec<(usize, usize, RetryLimit)> = Vec::new();
let _ = run_hurl_streaming_with(
&content,
&HashMap::new(),
None,
|_, _| EntrySetup::Bind(Vec::new()),
|_| {},
|i, attempt, limit| seen.push((i, attempt, limit)),
);
assert_eq!(
seen,
vec![
(0, 0, RetryLimit::Unknown),
(1, 0, RetryLimit::Times(3)),
(1, 1, RetryLimit::Times(3))
],
"the retry belongs to the second entry, and only it has a limit"
);
}
#[test]
fn a_poll_that_never_comes_good_still_fails() {
let port = polling_server(99);
let content = format!(
"GET http://127.0.0.1:{port}/\n[Options]\nretry: 1\nretry-interval: 20\nHTTP 200\n\
[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
let out = run_hurl(&content, &HashMap::new(), None);
let surviving: Vec<&EntryOutcome> = out.entries.iter().filter(|e| !e.superseded).collect();
assert_eq!(surviving.len(), 1);
assert!(!surviving[0].ok);
assert!(out.error.is_some(), "a failed run must say why");
}
#[test]
fn a_repeated_request_keeps_every_run() {
let port = polling_server(2);
let content = format!(
"GET http://127.0.0.1:{port}/\n[Options]\nrepeat: 3\nHTTP 200\n\
[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
let out = run_hurl(&content, &HashMap::new(), None);
assert_eq!(out.entries.len(), 3);
assert!(
out.entries.iter().all(|e| !e.superseded),
"a repeat's runs are all real"
);
assert!(out.error.is_some(), "two of the three runs failed");
}
#[test]
fn streaming_marks_the_superseded_attempts_too() {
let port = polling_server(2);
let content = format!(
"GET http://127.0.0.1:{port}/\n[Options]\nretry: 5\nretry-interval: 20\nHTTP 200\n\
[Asserts]\njsonpath \"$.result\" == \"Matched\"\n"
);
let mut seen: Vec<(usize, bool, bool)> = Vec::new();
let out = run_hurl_streaming_with(
&content,
&HashMap::new(),
None,
|_, _| EntrySetup::Bind(Vec::new()),
|eo| seen.push((eo.entry_index, eo.ok, eo.superseded)),
|_, _, _| {},
);
assert_eq!(
seen,
vec![(0, false, true), (0, false, true), (0, true, false)],
"the caller is told which attempts to ignore, as they happen"
);
assert!(out.error.is_none(), "{:?}", out.error);
}
#[test]
fn timing_breakdown_partitions_the_total() {
let port = one_shot_server(200, "OK");
let content = format!("GET http://127.0.0.1:{port}/\nHTTP 200\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
let parts = e.setup_ms + e.wait_ms + e.download_ms;
assert!(
parts <= e.duration_ms && e.duration_ms - parts <= 3,
"parts {parts} should account for total {}",
e.duration_ms
);
}
#[test]
fn status_line_appears_as_a_passing_assert() {
let port = one_shot_server(200, "OK");
let content = format!("GET http://127.0.0.1:{port}/\nHTTP 200\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert!(e.ok, "entry should pass, error: {:?}", e.error);
let status_assert = e
.asserts
.iter()
.find(|a| a.expr == "status == 200")
.expect("a `status == 200` assert row");
assert!(status_assert.passed);
}
fn one_shot_gzip_server(gzip_body: &'static [u8]) -> u16 {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
let head = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: gzip\r\nConnection: close\r\n\r\n";
let _ = sock.write_all(head);
let _ = sock.write_all(gzip_body);
let _ = sock.flush();
}
});
port
}
#[test]
fn gzip_response_body_is_decompressed() {
static GZIP_OK: &[u8] = &[
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xab, 0x56, 0xca, 0xcf,
0x56, 0xb2, 0x2a, 0x29, 0x2a, 0x4d, 0xad, 0x05, 0x00, 0x90, 0x5f, 0xd4, 0xa7, 0x0b,
0x00, 0x00, 0x00,
];
let port = one_shot_gzip_server(GZIP_OK);
let content = format!("GET http://127.0.0.1:{port}/\nHTTP 200\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert!(e.ok, "entry should pass, error: {:?}", e.error);
assert_eq!(e.raw_body, "{\"ok\":true}");
assert!(
e.body.contains("\"ok\": true"),
"body should be decompressed pretty JSON, got: {:?}",
e.body
);
}
#[test]
fn failed_status_assertion_has_a_clear_message() {
let port = one_shot_server(404, "Not Found");
let content = format!("GET http://127.0.0.1:{port}/\nHTTP 200\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert!(!e.ok);
let status_assert = e
.asserts
.iter()
.find(|a| a.expr == "status == 200")
.expect("a `status == 200` assert row");
assert!(!status_assert.passed);
assert!(
status_assert.detail.contains("404"),
"detail should show the actual status, got: {}",
status_assert.detail
);
let msg = e.error.as_deref().unwrap_or_default();
assert!(
msg.contains("Expected status 200") && msg.contains("got 404"),
"message should state expected vs actual, got: {msg}"
);
assert!(
!msg.contains("Assert status code"),
"message should not be the terse runner default, got: {msg}"
);
}
#[test]
fn wildcard_status_line_produces_no_status_assert() {
let port = one_shot_server(200, "OK");
let content = format!("GET http://127.0.0.1:{port}/\nHTTP *\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert!(
!e.asserts.iter().any(|a| a.expr.starts_with("status ==")),
"HTTP * should not synthesize a status assert"
);
}
#[test]
fn relative_form_file_path_is_authorized_against_the_collection_directory() {
let dir = temp_path(&std::env::temp_dir(), "paperboy_run_test");
std::fs::create_dir_all(&*dir).unwrap();
std::fs::write(dir.join("avatar.png"), b"fake-png").unwrap();
let content = "POST http://192.0.2.1/upload\n[Multipart]\navatar: file,avatar.png;\n";
let out = run_hurl(content, &HashMap::new(), Some(dir.as_path()));
let msg = out
.entries
.first()
.and_then(|e| e.error.as_deref())
.unwrap_or_default();
assert!(
!msg.to_ascii_lowercase().contains("unauthorized"),
"a form file relative to the collection directory must be authorized, got: {msg}"
);
}
#[test]
fn missing_file_root_falls_back_to_the_process_current_directory() {
let cwd = std::env::current_dir().unwrap();
let file_path = temp_path(&cwd, "paperboy_run_test_cwd");
let unique = file_path
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
std::fs::write(&*file_path, b"fake").unwrap();
let content = format!("POST http://192.0.2.1/upload\n[Multipart]\nf: file,{unique};\n");
let out = run_hurl(&content, &HashMap::new(), None);
let msg = out
.entries
.first()
.and_then(|e| e.error.as_deref())
.unwrap_or_default();
assert!(
!msg.to_ascii_lowercase().contains("unauthorized"),
"a file in the process's current directory must be authorized when no file_root is given, got: {msg}"
);
}
#[test]
fn form_file_path_outside_the_file_root_is_still_rejected() {
let root = temp_path(&std::env::temp_dir(), "paperboy_run_test_root");
let outside = temp_path(&std::env::temp_dir(), "paperboy_run_test_outside");
std::fs::create_dir_all(&*root).unwrap();
std::fs::create_dir_all(&*outside).unwrap();
std::fs::write(outside.join("secret.bin"), b"fake").unwrap();
let content = "POST http://192.0.2.1/upload\n[Multipart]\nf: file,../secret.bin;\n";
let out = run_hurl(content, &HashMap::new(), Some(root.as_path()));
let msg = out
.entries
.first()
.and_then(|e| e.error.as_deref())
.unwrap_or_default();
assert!(
msg.to_ascii_lowercase().contains("unauthorized"),
"a file outside file_root must still be rejected, got: {msg}"
);
}
#[test]
fn staging_authorizes_a_form_file_that_would_otherwise_be_rejected() {
use crate::hurl::entry::{FormField, FormFieldKind, HurlEntry};
use crate::hurl::stage_out_of_scope_form_files;
let root = temp_path(&std::env::temp_dir(), "paperboy_stage_run_root");
let outside = temp_path(&std::env::temp_dir(), "paperboy_stage_run_outside");
std::fs::create_dir_all(&*root).unwrap();
std::fs::create_dir_all(&*outside).unwrap();
let outside_file = outside.join("secret.bin");
std::fs::write(&outside_file, b"fake").unwrap();
let mut entries = vec![HurlEntry {
method: "POST".into(),
url: "http://192.0.2.1/upload".into(),
form_fields: vec![FormField {
key: "f".into(),
value: outside_file.to_string_lossy().into_owned(),
kind: FormFieldKind::File,
..Default::default()
}],
..Default::default()
}];
let staged = stage_out_of_scope_form_files(&mut entries, Some(root.as_path())).unwrap();
assert!(
staged.is_some(),
"an out-of-scope file must trigger staging"
);
let staged_dir = staged.unwrap();
let content = entries[0].to_hurl();
let out = run_hurl(&content, &HashMap::new(), Some(staged_dir.as_path()));
let msg = out
.entries
.first()
.and_then(|e| e.error.as_deref())
.unwrap_or_default();
assert!(
!msg.to_ascii_lowercase().contains("unauthorized"),
"the staged file must be authorized against the staging directory, got: {msg}"
);
std::fs::remove_dir_all(&staged_dir).ok();
}
#[test]
fn error_status_responses_keep_their_body() {
for status in [400u16, 401, 404, 422, 500, 502, 503] {
let port = one_shot_server(status, "Err");
let content = format!("GET http://127.0.0.1:{port}/\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert_eq!(e.status, status);
assert!(e.ok, "no expectation means nothing to fail: {:?}", e.error);
assert_eq!(
e.raw_body, "{\"ok\":true}",
"the {status} body must survive verbatim"
);
assert!(
e.body.contains("\"ok\""),
"and be pretty-printed for display: {:?}",
e.body
);
let port = one_shot_server(status, "Err");
let content = format!("GET http://127.0.0.1:{port}/\nHTTP 200\n");
let out = run_hurl(&content, &HashMap::new(), None);
let e = out.entries.first().expect("one entry");
assert_eq!(e.status, status);
assert!(!e.ok, "expected 200, got {status}");
assert!(
e.error.as_deref().unwrap_or_default().contains("200"),
"the mismatch is reported: {:?}",
e.error
);
assert_eq!(
e.raw_body, "{\"ok\":true}",
"a failed status assert must not discard the {status} body"
);
}
}
fn echo_path_server(n: usize) -> u16 {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for _ in 0..n {
let Ok((mut sock, _)) = listener.accept() else {
return;
};
let mut buf = [0u8; 2048];
let read = sock.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..read]).to_string();
let path = req.split_whitespace().nth(1).unwrap_or("/").to_string();
let body = format!("{{\"path\":\"{path}\"}}");
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes());
let _ = sock.flush();
}
});
port
}
#[test]
fn every_outcome_of_a_repeated_request_names_that_request() {
let port = echo_path_server(3);
let content = format!(
"GET http://127.0.0.1:{port}/first\n[Options]\nrepeat: 2\nHTTP 200\n\nGET http://127.0.0.1:{port}/second\nHTTP 200\n"
);
let out = run_hurl(&content, &HashMap::new(), None);
assert_eq!(out.entries.len(), 3, "two repeats, then the second request");
assert_eq!(
out.entries
.iter()
.map(|e| e.entry_index)
.collect::<Vec<_>>(),
vec![0, 0, 1],
"both repeats belong to request 0"
);
assert!(
out.entries[2].url.ends_with("/second"),
"and the last outcome really is the second request"
);
}
}