use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::{Arc, Mutex};
use std::thread;
use base64::{Engine, engine::general_purpose::STANDARD};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::collection::Collection;
use crate::environment::{EnvUpdate, Environment, ValueSource, substitute};
use crate::generators::GenError;
use crate::http::ApiResponse;
use crate::hurl::{
EntryOutcome, FormField, HurlEntry, KvRow, RunOutput, RunStatus, collection_to_hurl,
expand_base64_form_fields, run_hurl, stage_out_of_scope_form_files,
};
#[derive(Clone)]
pub struct AppVars {
pub base_url: String,
}
impl Default for AppVars {
fn default() -> Self {
Self {
base_url: "http://127.0.0.1:8080".to_string(),
}
}
}
pub fn collection_vars(
env: Option<&Environment>,
captures: &HashMap<String, String>,
) -> HashMap<String, String> {
let mut vars = HashMap::new();
if let Some(env) = env {
for v in &env.vars {
vars.insert(v.key.clone(), v.value.clone());
}
}
for (k, v) in captures {
vars.insert(k.clone(), v.clone());
}
vars
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SubstKind {
Literal,
Loaded,
Pending,
Failed,
Computed,
Undefined,
}
pub struct SubstInfo {
pub shown: Option<String>,
pub kind: SubstKind,
}
pub fn subst_map(col: &Collection, env: Option<&Environment>) -> HashMap<String, SubstInfo> {
let mut out: HashMap<String, SubstInfo> = HashMap::new();
for e in &col.entries {
for (name, _) in &e.captures {
out.insert(
name.clone(),
SubstInfo {
shown: None,
kind: SubstKind::Failed,
},
);
}
}
if let Some(env) = env {
for v in &env.vars {
let info = if v.loading {
SubstInfo {
shown: None,
kind: SubstKind::Pending,
}
} else if v.resolved {
match v.source {
ValueSource::Literal => SubstInfo {
shown: Some(v.value.clone()),
kind: SubstKind::Literal,
},
_ => SubstInfo {
shown: Some(v.display_value()),
kind: SubstKind::Loaded,
},
}
} else {
SubstInfo {
shown: None,
kind: SubstKind::Failed,
}
};
out.insert(v.key.clone(), info);
}
}
for (k, val) in &col.captures {
out.insert(
k.clone(),
SubstInfo {
shown: Some(val.clone()),
kind: SubstKind::Loaded,
},
);
}
for e in &col.entries {
for (name, _) in &e.generators {
out.insert(
name.clone(),
SubstInfo {
shown: None,
kind: SubstKind::Computed,
},
);
}
}
out
}
pub fn subst_display(text: &str, map: &HashMap<String, SubstInfo>) -> String {
let mut out = String::new();
let mut rest = text;
while let Some(open) = rest.find("{{") {
let Some(close_rel) = rest[open + 2..].find("}}") else {
break;
};
let close = open + 2 + close_rel;
let end = close + 2;
let inner = rest[open + 2..close].trim();
out.push_str(&rest[..open]);
match map.get(inner).and_then(|i| i.shown.as_ref()) {
Some(val) => out.push_str(val),
None => out.push_str(&rest[open..end]),
}
rest = &rest[end..];
}
out.push_str(rest);
out
}
#[derive(Clone)]
struct KvValue {
value: String,
enabled: bool,
desc: String,
}
impl serde::Serialize for KvValue {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
if self.enabled && self.desc.is_empty() {
s.serialize_str(&self.value)
} else if self.desc.is_empty() {
(&self.value, self.enabled).serialize(s)
} else {
(&self.value, self.enabled, &self.desc).serialize(s)
}
}
}
impl<'de> serde::Deserialize<'de> for KvValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(match Value::deserialize(d)? {
Value::Array(arr) => KvValue {
value: arr.first().map(value_as_text).unwrap_or_default(),
enabled: arr.get(1).and_then(Value::as_bool).unwrap_or(true),
desc: arr.get(2).map(value_as_text).unwrap_or_default(),
},
other => KvValue {
value: value_as_text(&other),
enabled: true,
desc: String::new(),
},
})
}
}
#[derive(Clone, Default)]
struct TextValue(String);
impl serde::Serialize for TextValue {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> serde::Deserialize<'de> for TextValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(TextValue(value_as_text(&Value::deserialize(d)?)))
}
}
fn value_as_text(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
#[derive(Serialize, Default, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum FormKind {
#[default]
Text,
File,
#[serde(rename = "base64file")]
Base64File,
}
impl<'de> serde::Deserialize<'de> for FormKind {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
Ok(match v.as_str() {
Some("file") => FormKind::File,
Some("base64file") => FormKind::Base64File,
_ => FormKind::Text,
})
}
}
#[derive(Serialize, Deserialize)]
struct FormFieldJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
base64_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_type: Option<String>,
#[serde(default)]
key: TextValue,
#[serde(rename = "type", default)]
kind: FormKind,
#[serde(default)]
value: TextValue,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
enabled: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
desc: String,
}
fn default_true() -> bool {
true
}
fn is_true(b: &bool) -> bool {
*b
}
impl From<&FormField> for FormFieldJson {
fn from(f: &FormField) -> Self {
Self {
base64_prefix: f.base64_prefix.clone(),
content_type: f.content_type.clone(),
key: TextValue(f.key.clone()),
kind: match f.kind {
crate::hurl::FormFieldKind::File => FormKind::File,
crate::hurl::FormFieldKind::Base64File => FormKind::Base64File,
crate::hurl::FormFieldKind::Text => FormKind::Text,
},
value: TextValue(f.value.clone()),
enabled: f.enabled,
desc: f.desc.clone(),
}
}
}
impl From<FormFieldJson> for FormField {
fn from(f: FormFieldJson) -> Self {
Self {
key: f.key.0,
value: f.value.0,
kind: match f.kind {
FormKind::File => crate::hurl::FormFieldKind::File,
FormKind::Base64File => crate::hurl::FormFieldKind::Base64File,
FormKind::Text => crate::hurl::FormFieldKind::Text,
},
content_type: f.content_type,
base64_prefix: f.base64_prefix,
enabled: f.enabled,
desc: f.desc,
}
}
}
#[derive(Serialize, Deserialize, Default)]
struct BasicAuthJson {
#[serde(default)]
pass: TextValue,
#[serde(default)]
user: TextValue,
}
#[derive(Serialize, Deserialize)]
struct RequestJson {
#[serde(default, skip_serializing_if = "Option::is_none")]
basic_auth: Option<BasicAuthJson>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
cookies: BTreeMap<String, KvValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
form_fields: Vec<FormFieldJson>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
headers: BTreeMap<String, KvValue>,
method: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
query_params: BTreeMap<String, KvValue>,
url: String,
}
fn rows_to_map(rows: &[KvRow]) -> BTreeMap<String, KvValue> {
rows.iter()
.map(|r| {
(
r.key.clone(),
KvValue {
value: r.value.clone(),
enabled: r.enabled,
desc: r.desc.clone(),
},
)
})
.collect()
}
fn map_to_rows(map: BTreeMap<String, KvValue>) -> Vec<KvRow> {
map.into_iter()
.map(|(key, kv)| KvRow {
key,
value: kv.value,
enabled: kv.enabled,
desc: kv.desc,
})
.collect()
}
pub fn build_request_json(entry: &HurlEntry) -> String {
let dto = RequestJson {
basic_auth: entry.basic_auth.as_ref().map(|(user, pass)| BasicAuthJson {
pass: TextValue(pass.clone()),
user: TextValue(user.clone()),
}),
body: entry.body_src.as_deref().map(|raw| {
serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}),
cookies: rows_to_map(&entry.cookies),
form_fields: entry.form_fields.iter().map(FormFieldJson::from).collect(),
headers: rows_to_map(&entry.headers),
method: entry.method.clone(),
query_params: rows_to_map(&entry.queries),
url: entry.url.clone(),
};
serde_json::to_string_pretty(&dto).unwrap_or_else(|_| "{}".into())
}
pub fn apply_request_json(base: &HurlEntry, text: &str) -> Result<HurlEntry, String> {
let dto: RequestJson = serde_json::from_str(text).map_err(|e| e.to_string())?;
let body = match dto.body {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(s),
Some(v) => Some(serde_json::to_string_pretty(&v).unwrap_or_default()),
};
let mut entry = base.clone();
entry.method = dto.method;
entry.url = dto.url;
entry.basic_auth = dto.basic_auth.map(|ba| (ba.user.0, ba.pass.0));
entry.headers = map_to_rows(dto.headers);
entry.cookies = map_to_rows(dto.cookies);
entry.queries = map_to_rows(dto.query_params);
entry.form_fields = dto.form_fields.into_iter().map(FormField::from).collect();
entry.body_src = body;
Ok(entry)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum RequestView {
Json,
#[default]
Hurl,
}
pub struct ResolvedRequest {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub cookies: Vec<(String, String)>,
pub form_fields: Vec<FormField>,
pub body: Option<String>,
}
pub fn resolve_entry(entry: &HurlEntry, vars: &HashMap<String, String>) -> ResolvedRequest {
let method = entry.method.clone();
let url = substitute(&entry.url, vars);
let mut headers: Vec<(String, String)> = entry
.headers
.iter()
.filter(|r| r.enabled)
.map(|r| (r.key.clone(), substitute(&r.value, vars)))
.collect();
if let Some((user, pass)) = &entry.basic_auth {
let cred = STANDARD.encode(format!(
"{}:{}",
substitute(user, vars),
substitute(pass, vars)
));
headers.push(("Authorization".to_string(), format!("Basic {cred}")));
}
let cookies: Vec<(String, String)> = entry
.cookies
.iter()
.filter(|r| r.enabled)
.map(|r| (substitute(&r.key, vars), substitute(&r.value, vars)))
.collect();
let form_fields: Vec<FormField> = entry
.form_fields
.iter()
.filter(|f| f.enabled)
.map(|f| FormField {
key: substitute(&f.key, vars),
value: substitute(&f.value, vars),
kind: f.kind,
content_type: f.content_type.as_deref().map(|ct| substitute(ct, vars)),
base64_prefix: f.base64_prefix.as_deref().map(|p| substitute(p, vars)),
enabled: f.enabled,
desc: String::new(),
})
.collect();
let body = entry.body_wire().as_deref().map(|b| substitute(b, vars));
ResolvedRequest {
method,
url,
headers,
cookies,
form_fields,
body,
}
}
pub struct CaptureUpdate {
pub col_id: u64,
pub entry_idx: usize,
pub ok: bool,
pub values: HashMap<String, String>,
pub response: ApiResponse,
}
pub struct BatchRunUpdate {
pub col_id: u64,
pub results: Vec<Option<bool>>,
pub captures: HashMap<String, String>,
pub responses: Vec<Option<ApiResponse>>,
}
fn to_run_entry(base: &HurlEntry, resolved: ResolvedRequest) -> HurlEntry {
let is_multipart = resolved
.form_fields
.iter()
.any(|form| form.kind.is_multipart());
HurlEntry {
uid: 0,
unparsed: None,
title: String::new(),
method: resolved.method,
url: resolved.url,
headers: resolved
.headers
.iter()
.map(|(k, v)| KvRow::new(k.clone(), v.clone()))
.collect(),
basic_auth: None, form_fields: resolved.form_fields,
is_multipart,
queries: base.queries.clone(),
cookies: resolved
.cookies
.iter()
.map(|(k, v)| KvRow::new(k.clone(), v.clone()))
.collect(),
options: base.options.clone(),
body_src: resolved.body,
expected_status: base.expected_status,
response_version: base.response_version.clone(),
response_headers: base.response_headers.clone(),
response_body: base.response_body.clone(),
captures: base.captures.clone(),
asserts: base.asserts.clone(),
reports: base.reports.clone(),
generators: Vec::new(),
comments: Vec::new(),
user_added: base.user_added,
modified: base.modified,
baseline: None,
last_run: base.last_run,
last_response: None,
}
}
pub fn effective_vars_reporting<'a>(
base: &HurlEntry,
vars: &'a HashMap<String, String>,
) -> (Cow<'a, HashMap<String, String>>, Vec<GenError>) {
effective_vars_with(base, vars, &crate::generators::SystemSource::new())
}
pub fn effective_vars_with<'a>(
base: &HurlEntry,
vars: &'a HashMap<String, String>,
src: &dyn crate::generators::GenSource,
) -> (Cow<'a, HashMap<String, String>>, Vec<GenError>) {
let defaults = base.variable_defaults();
if defaults.is_empty() && base.generators.is_empty() {
return (Cow::Borrowed(vars), Vec::new());
}
let mut merged = vars.clone();
for (name, value) in defaults {
if merged.contains_key(&name) {
continue;
}
let value = substitute(&value, &merged);
merged.insert(name, value);
}
let errors = crate::generators::expand(&base.generators, &mut merged, src);
(Cow::Owned(merged), errors)
}
fn strip_variable_options(entry: &mut HurlEntry) {
entry
.options
.retain(|r| !(r.enabled && r.key.trim().eq_ignore_ascii_case("variable")));
}
pub fn strip_bound_variable_options(
entries: &mut [HurlEntry],
vars: &HashMap<String, String>,
) -> bool {
let mut stripped = false;
for entry in entries {
let bound: Vec<String> = entry
.variable_defaults()
.into_iter()
.filter(|(name, _)| vars.contains_key(name))
.map(|(name, _)| name)
.collect();
if bound.is_empty() {
continue;
}
entry.options.retain(|r| {
let is_bound_default = r.enabled
&& r.key.trim().eq_ignore_ascii_case("variable")
&& r.value
.split_once('=')
.is_some_and(|(name, _)| bound.iter().any(|b| b == name.trim()));
!is_bound_default
});
stripped = true;
}
stripped
}
pub fn run_resolved_entry(
base: &HurlEntry,
vars: &HashMap<String, String>,
file_root: Option<&std::path::Path>,
extra_captures: &[(String, String)],
) -> RunOutput {
run_resolved_entry_reporting(base, vars, file_root, extra_captures).0
}
pub fn run_resolved_entry_reporting(
base: &HurlEntry,
vars: &HashMap<String, String>,
file_root: Option<&std::path::Path>,
extra_captures: &[(String, String)],
) -> (RunOutput, HashMap<String, String>, Vec<GenError>) {
if base.is_unreadable() {
return (
RunOutput {
entries: vec![],
error: Some(UNREADABLE_REQUEST_ERROR.to_string()),
},
HashMap::new(),
Vec::new(),
);
}
let (vars, gen_errors) = effective_vars_reporting(base, vars);
if !gen_errors.is_empty() {
let english = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
return (
RunOutput {
entries: vec![],
error: Some(crate::i18n::summarise_gen_errors(&english, &gen_errors).join("; ")),
},
HashMap::new(),
gen_errors,
);
}
let generated: HashMap<String, String> = base
.generators
.iter()
.filter_map(|(name, _)| vars.get(name).map(|v| (name.clone(), v.clone())))
.collect();
let resolved = resolve_entry(base, &vars);
let mut run_entry = to_run_entry(base, resolved);
run_entry.captures.extend(extra_captures.iter().cloned());
strip_variable_options(&mut run_entry);
let mut entries = [run_entry];
if let Err(e) = expand_base64_form_fields(&mut entries, file_root) {
return (
RunOutput {
entries: vec![],
error: Some(format!("Base64 file error: {e}")),
},
generated,
Vec::new(),
);
}
let staged_dir = stage_out_of_scope_form_files(&mut entries, file_root).unwrap_or_default();
let mut run_entry = entries.into_iter().next().unwrap();
run_entry.ensure_run_content_length();
let run_root = staged_dir.as_deref().or(file_root);
let content = run_entry.to_hurl();
let out = run_hurl(&content, &vars, run_root);
if let Some(dir) = &staged_dir {
let _ = std::fs::remove_dir_all(dir);
}
(out, generated, Vec::new())
}
pub const UNREADABLE_REQUEST_ERROR: &str = "This request could not be read from the file, so there is nothing to send. \
Open it in Raw Mode (Shift+H) to repair the Hurl text.";
const BODY_FORM_CONFLICT_ERROR: &str = "Can't send: a request can't have both a Body and Form/Multipart fields (Hurl sends the body and silently drops the fields) — remove one.";
pub fn run_collection(
col: &Collection,
env: Option<&Environment>,
state: Arc<Mutex<ApiResponse>>,
) -> Option<Receiver<CaptureUpdate>> {
let base = col.entries.get(col.selected_entry)?;
if base.body_form_conflict() {
let mut r = state.lock().unwrap();
r.loading = false;
r.error = BODY_FORM_CONFLICT_ERROR.to_string();
return None;
}
let base = base.clone();
let vars = collection_vars(env, &col.captures);
let col_id = col.id;
let entry_idx = col.selected_entry;
let file_root = col
.path
.as_ref()
.and_then(|p| p.parent().map(std::path::PathBuf::from));
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let (out, generated, gen_errors) =
run_resolved_entry_reporting(&base, &vars, file_root.as_deref(), &[]);
let mut r = state.lock().unwrap();
r.loading = false;
r.gen_errors = gen_errors;
match out.entries.into_iter().next() {
Some(eo) => {
r.status = eo.status;
r.status_text = eo.status_text;
r.body = Arc::from(eo.body);
r.headers = eo.headers;
r.assert_results = eo.asserts;
r.duration_ms = Some(eo.duration_ms);
r.error = eo.error.or(out.error).unwrap_or_default();
let mut values: HashMap<String, String> = generated;
values.extend(eo.captures);
let _ = tx.send(CaptureUpdate {
col_id,
entry_idx,
ok: eo.ok,
values,
response: r.clone(),
});
}
None => {
r.error = out.error.unwrap_or_else(|| "no response".to_string());
let _ = tx.send(CaptureUpdate {
col_id,
entry_idx,
ok: false,
values: HashMap::new(),
response: r.clone(),
});
}
}
});
Some(rx)
}
fn entry_response(eo: &EntryOutcome) -> ApiResponse {
ApiResponse {
status: eo.status,
status_text: eo.status_text.clone(),
body: Arc::from(eo.body.as_str()),
loading: false,
error: eo.error.clone().unwrap_or_default(),
headers: eo.headers.clone(),
assert_results: eo.asserts.clone(),
duration_ms: Some(eo.duration_ms),
gen_errors: Vec::new(),
}
}
pub fn run_all_entries(
col: &Collection,
env: Option<&Environment>,
state: Arc<Mutex<ApiResponse>>,
batch: bool,
) -> Option<Receiver<BatchRunUpdate>> {
if col.entries.is_empty() {
return None;
}
if let Some(bad) = col.entries.iter().find(|e| e.body_form_conflict()) {
let mut r = state.lock().unwrap();
r.loading = false;
r.error = format!("{} ({})", BODY_FORM_CONFLICT_ERROR, bad.title);
return None;
}
let vars = collection_vars(env, &col.captures);
let col_id = col.id;
let file_root = col
.path
.as_ref()
.and_then(|p| p.parent().map(std::path::PathBuf::from));
let total = col.entries.len();
let run_positions: Vec<usize> = col
.entries
.iter()
.enumerate()
.filter(|(_, e)| !e.is_unreadable())
.map(|(i, _)| i)
.collect();
if run_positions.is_empty() {
return None;
}
let mut run_entries: Vec<HurlEntry> = run_positions
.iter()
.map(|&i| col.entries[i].clone())
.collect();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
if let Err(e) = expand_base64_form_fields(&mut run_entries, file_root.as_deref()) {
let mut r = state.lock().unwrap();
r.loading = false;
r.error = format!("Base64 file error: {e}");
return;
}
let staged_dir = stage_out_of_scope_form_files(&mut run_entries, file_root.as_deref())
.unwrap_or_default();
let run_root = staged_dir.as_deref().or(file_root.as_deref());
for e in &mut run_entries {
e.ensure_run_content_length();
}
strip_bound_variable_options(&mut run_entries, &vars);
let content = collection_to_hurl(&run_entries);
let mut results: Vec<Option<bool>> = vec![None; total];
let mut captures: HashMap<String, String> = HashMap::new();
let mut responses: Vec<Option<ApiResponse>> = vec![None; total];
let mut vars = vars;
if batch {
let blocks = expand_batch_generators(
&run_entries,
&vars,
&crate::generators::SystemSource::new(),
);
if !blocks.errors.is_empty() {
let flat: Vec<crate::generators::GenError> = blocks
.errors
.iter()
.flat_map(|(_, errs)| errs.iter().cloned())
.collect();
let english = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
let mut r = state.lock().unwrap();
r.loading = false;
r.error = crate::i18n::summarise_gen_errors(&english, &flat).join("; ");
r.gen_errors = flat;
if let Some(dir) = &staged_dir {
let _ = std::fs::remove_dir_all(dir);
}
return;
}
captures.extend(blocks.bound.iter().map(|(k, v)| (k.clone(), v.clone())));
vars.extend(blocks.bound);
}
let out = if batch {
run_hurl(&content, &vars, run_root)
} else {
let gen_entries = run_entries.clone();
let generated: Rc<RefCell<HashMap<String, String>>> = Rc::default();
let record_gen = Rc::clone(&generated);
let gen_errors: Rc<RefCell<Vec<crate::generators::GenError>>> = Rc::default();
let record_errs = Rc::clone(&gen_errors);
let mut streamed = crate::hurl::run::run_hurl_streaming_with(
&content,
&vars,
run_root,
|i, known| {
let Some(entry) = gen_entries.get(i) else {
return crate::hurl::EntrySetup::Bind(Vec::new());
};
if entry.generators.is_empty() {
return crate::hurl::EntrySetup::Bind(Vec::new());
}
let mut merged = known.clone();
let errs = crate::generators::expand(
&entry.generators,
&mut merged,
&crate::generators::SystemSource::new(),
);
if !errs.is_empty() {
let english =
crate::i18n::Strings::for_language(&crate::i18n::Language::English);
let reason = crate::i18n::summarise_gen_errors(&english, &errs).join("; ");
record_errs.borrow_mut().extend(errs);
return crate::hurl::EntrySetup::Skip { reason };
}
let bound: Vec<(String, String)> = entry
.generators
.iter()
.filter_map(|(name, _)| merged.get(name).map(|v| (name.clone(), v.clone())))
.collect();
record_gen.borrow_mut().extend(bound.iter().cloned());
crate::hurl::EntrySetup::Bind(bound)
},
|eo| {
if let Some(&at) = run_positions.get(eo.entry_index) {
results[at] = Some(eo.ok);
captures.extend(
generated
.borrow()
.iter()
.map(|(k, v)| (k.clone(), v.clone())),
);
captures.extend(eo.captures.iter().cloned());
responses[at] = Some(entry_response(eo));
}
let _ = tx.send(BatchRunUpdate {
col_id,
results: results.clone(),
captures: captures.clone(),
responses: responses.clone(),
});
},
);
if streamed.error.is_none() {
let errs = gen_errors.borrow();
if !errs.is_empty() {
let english =
crate::i18n::Strings::for_language(&crate::i18n::Language::English);
streamed.error =
Some(crate::i18n::summarise_gen_errors(&english, &errs).join("; "));
}
}
streamed
};
if let Some(dir) = &staged_dir {
let _ = std::fs::remove_dir_all(dir);
}
let mut r = state.lock().unwrap();
r.loading = false;
match out.entries.last() {
Some(last) => {
r.status = last.status;
r.status_text = last.status_text.clone();
r.body = Arc::from(last.body.as_str());
r.headers = last.headers.clone();
r.assert_results = last.asserts.clone();
r.error = last
.error
.clone()
.or_else(|| out.error.clone())
.unwrap_or_default();
}
None => {
r.error = out
.error
.clone()
.unwrap_or_else(|| "no response".to_string());
}
}
if batch {
for eo in out.entries.iter() {
let Some(&at) = run_positions.get(eo.entry_index) else {
continue;
};
results[at] = Some(eo.ok);
captures.extend(eo.captures.iter().cloned());
responses[at] = Some(entry_response(eo));
}
let _ = tx.send(BatchRunUpdate {
col_id,
results,
captures,
responses,
});
}
});
Some(rx)
}
pub fn drain_capture_updates(
pending: &mut Vec<Receiver<CaptureUpdate>>,
collections: &mut [Collection],
) -> bool {
if pending.is_empty() {
return false;
}
let mut still = Vec::new();
for rx in std::mem::take(pending) {
let mut disconnected = false;
loop {
match rx.try_recv() {
Ok(update) => {
for col in collections.iter_mut() {
if col.id == update.col_id {
for (k, v) in &update.values {
col.captures.insert(k.clone(), v.clone());
}
col.invalidate_request_json();
if let Some(entry) = col.entries.get_mut(update.entry_idx) {
entry.last_response = Some(update.response.clone());
entry.last_run = if update.ok {
RunStatus::Passed
} else {
RunStatus::Failed
};
}
}
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
if !disconnected {
still.push(rx);
}
}
*pending = still;
!pending.is_empty()
}
fn for_each_wire_text(entry: &HurlEntry, mut visit: impl FnMut(&str)) {
visit(&entry.url);
for r in entry.headers.iter().chain(&entry.queries) {
visit(&r.key);
visit(&r.value);
}
for f in &entry.form_fields {
visit(&f.key);
visit(&f.value);
}
for r in &entry.cookies {
visit(&r.key);
visit(&r.value);
}
if let Some((u, p)) = &entry.basic_auth {
visit(u);
visit(p);
}
if let Some(body) = entry.body_wire() {
visit(&body);
}
}
pub fn entry_referenced_keys(entry: &HurlEntry) -> std::collections::HashSet<String> {
let mut keys = std::collections::HashSet::new();
for_each_wire_text(entry, |text| {
keys.extend(crate::environment::referenced_keys(text))
});
keys
}
pub fn entry_placeholder_problems(entry: &HurlEntry) -> Vec<crate::hurl::PlaceholderProblem> {
let mut out: Vec<crate::hurl::PlaceholderProblem> = Vec::new();
for_each_wire_text(entry, |text| {
for p in crate::hurl::placeholder_problems(text) {
if !out.contains(&p) {
out.push(p);
}
}
});
out
}
pub fn pending_request_keys(col: &Collection, env: Option<&Environment>) -> Vec<String> {
let Some(env) = env else { return Vec::new() };
let Some(entry) = col.entries.get(col.selected_entry) else {
return Vec::new();
};
let referenced = entry_referenced_keys(entry);
let mut blocking: Vec<String> = env
.vars
.iter()
.filter(|v| v.is_pending() && referenced.contains(&v.key))
.map(|v| v.key.clone())
.collect();
blocking.sort();
blocking.dedup();
blocking
}
pub fn pending_request_keys_all(col: &Collection, env: Option<&Environment>) -> Vec<String> {
let Some(env) = env else { return Vec::new() };
let mut referenced = std::collections::HashSet::new();
for entry in &col.entries {
referenced.extend(entry_referenced_keys(entry));
}
let mut blocking: Vec<String> = env
.vars
.iter()
.filter(|v| v.is_pending() && referenced.contains(&v.key))
.map(|v| v.key.clone())
.collect();
blocking.sort();
blocking.dedup();
blocking
}
fn defined_keys(col: &Collection, env: Option<&Environment>) -> std::collections::HashSet<String> {
let mut defined: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Some(env) = env {
defined.extend(env.vars.iter().map(|v| v.key.clone()));
}
for entry in &col.entries {
defined.extend(entry.captures.iter().map(|(name, _)| name.clone()));
defined.extend(entry.generators.iter().map(|(name, _)| name.clone()));
}
defined.extend(col.captures.keys().cloned());
defined
}
pub fn undefined_request_keys(col: &Collection, env: Option<&Environment>) -> Vec<String> {
let Some(entry) = col.entries.get(col.selected_entry) else {
return Vec::new();
};
let defined = defined_keys(col, env);
let mut out: Vec<String> = entry_referenced_keys(entry)
.into_iter()
.filter(|k| !defined.contains(k))
.collect();
out.sort();
out
}
pub fn undefined_request_keys_all(col: &Collection, env: Option<&Environment>) -> Vec<String> {
let defined = defined_keys(col, env);
let mut out: Vec<String> = col
.entries
.iter()
.flat_map(entry_referenced_keys)
.filter(|k| !defined.contains(k))
.collect();
out.sort();
out.dedup();
out
}
pub fn body_form_conflicts(col: &Collection) -> Vec<String> {
col.entries
.get(col.selected_entry)
.filter(|e| e.body_form_conflict())
.map(|e| vec![request_label(e)])
.unwrap_or_default()
}
pub fn body_form_conflicts_all(col: &Collection) -> Vec<String> {
col.entries
.iter()
.filter(|e| e.body_form_conflict())
.map(request_label)
.collect()
}
pub fn truncated_placeholders(col: &Collection) -> Vec<String> {
col.entries
.get(col.selected_entry)
.map(|e| describe_placeholder_problems(&entry_placeholder_problems(e)))
.unwrap_or_default()
}
pub fn truncated_placeholders_all(col: &Collection) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for e in &col.entries {
for d in describe_placeholder_problems(&entry_placeholder_problems(e)) {
if !out.contains(&d) {
out.push(d);
}
}
}
out
}
pub fn generator_problems(col: &Collection, env: Option<&Environment>) -> Vec<GenError> {
let Some(entry) = col.entries.get(col.selected_entry) else {
return Vec::new();
};
describe_generator_errors(entry, env, &col.captures)
}
pub fn generator_problems_all(col: &Collection, env: Option<&Environment>) -> Vec<GenError> {
let mut out: Vec<GenError> = Vec::new();
for entry in &col.entries {
for d in describe_generator_errors(entry, env, &col.captures) {
if !out.contains(&d) {
out.push(d);
}
}
}
out
}
pub struct BatchGenerators {
pub bound: HashMap<String, String>,
pub errors: Vec<(String, Vec<GenError>)>,
pub collisions: Vec<String>,
pub shadowed: Vec<String>,
}
pub fn expand_batch_generators(
entries: &[crate::hurl::HurlEntry],
vars: &HashMap<String, String>,
src: &dyn crate::generators::GenSource,
) -> BatchGenerators {
let mut bound = HashMap::new();
let mut errors = Vec::new();
let mut collisions: Vec<String> = Vec::new();
let mut shadowed: Vec<String> = Vec::new();
let mut claimed: HashMap<String, usize> = HashMap::new();
for (i, e) in entries.iter().enumerate() {
if e.generators.is_empty() {
continue;
}
let mut merged = vars.clone();
merged.extend(
bound
.iter()
.map(|(k, v): (&String, &String)| (k.clone(), v.clone())),
);
let errs = crate::generators::expand(&e.generators, &mut merged, src);
if !errs.is_empty() {
errors.push((e.title.clone(), errs));
}
for (name, _) in &e.generators {
match claimed.get(name) {
Some(&first) if first != i => {
if !collisions.contains(name) {
collisions.push(name.clone());
}
continue;
}
_ => {}
}
claimed.entry(name.clone()).or_insert(i);
if vars.contains_key(name) {
if !shadowed.contains(name) {
shadowed.push(name.clone());
}
continue;
}
if let Some(v) = merged.get(name) {
bound.entry(name.clone()).or_insert_with(|| v.clone());
}
}
}
BatchGenerators {
bound,
errors,
collisions,
shadowed,
}
}
pub fn generator_collisions(col: &Collection) -> Vec<String> {
let mut claimed: Vec<&str> = Vec::new();
let mut out: Vec<String> = Vec::new();
for e in &col.entries {
let mut seen_here: Vec<&str> = Vec::new();
for (name, _) in &e.generators {
if seen_here.contains(&name.as_str()) {
continue;
}
seen_here.push(name);
if claimed.contains(&name.as_str()) {
if !out.contains(name) {
out.push(name.clone());
}
} else {
claimed.push(name);
}
}
}
out
}
pub fn generator_env_shadows(col: &Collection, env: Option<&Environment>) -> Vec<String> {
let vars = collection_vars(env, &col.captures);
let mut out: Vec<String> = Vec::new();
for e in &col.entries {
for (name, _) in &e.generators {
if vars.contains_key(name) && !out.contains(name) {
out.push(name.clone());
}
}
}
out
}
fn describe_generator_errors(
entry: &crate::hurl::HurlEntry,
env: Option<&Environment>,
captures: &HashMap<String, String>,
) -> Vec<GenError> {
if entry.generators.is_empty() {
return Vec::new();
}
let vars = collection_vars(env, captures);
effective_vars_with(entry, &vars, &crate::generators::DryRunSource).1
}
fn describe_placeholder_problems(problems: &[crate::hurl::PlaceholderProblem]) -> Vec<String> {
use crate::hurl::PlaceholderProblem as P;
problems
.iter()
.map(|p| match p {
P::Truncated { written, read } => format!("{written} → {read}"),
P::Unparsable { written } => written.clone(),
})
.collect()
}
fn request_label(e: &crate::hurl::HurlEntry) -> String {
if e.title.trim().is_empty() {
format!("{} {}", e.method, e.url)
} else {
e.title.clone()
}
}
pub fn drain_env_updates(
pending: &mut Vec<Receiver<EnvUpdate>>,
global_envs: &mut [Environment],
collections: &mut [Collection],
) -> bool {
if pending.is_empty() {
return false;
}
let mut still = Vec::new();
for rx in std::mem::take(pending) {
let mut disconnected = false;
loop {
match rx.try_recv() {
Ok(update) => {
for env in global_envs.iter_mut() {
if env.id == update.env_id {
env.apply_update(&update);
for col in collections.iter_mut() {
col.invalidate_request_json();
}
}
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
if !disconnected {
still.push(rx);
}
}
*pending = still;
!pending.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collection::Collection;
use crate::environment::{EnvVar, Environment, ValueSource};
use crate::hurl::{FormField, FormFieldKind, HurlEntry};
#[test]
fn request_json_is_alphabetically_ordered_and_round_trips() {
let entry = HurlEntry {
method: "POST".into(),
url: "http://example.com/api".into(),
headers: vec![
KvRow::toggled("X-Zed", "z", true),
KvRow::toggled("Authorization", "Bearer t", true),
],
cookies: vec![KvRow::toggled("session", "abc", true)],
queries: vec![KvRow::toggled("page", "2", true)],
basic_auth: Some(("alice".into(), "secret".into())),
form_fields: vec![
FormField {
key: "name".into(),
value: "widget".into(),
kind: FormFieldKind::Text,
content_type: None,
base64_prefix: None,
enabled: true,
desc: String::new(),
},
FormField {
key: "file".into(),
value: "./a.bin".into(),
kind: FormFieldKind::File,
content_type: Some("application/octet-stream".into()),
base64_prefix: None,
enabled: true,
desc: String::new(),
},
],
body_src: Some(r#"{"a":1}"#.into()),
..Default::default()
};
let json = build_request_json(&entry);
let expected = r#"{
"basic_auth": {
"pass": "secret",
"user": "alice"
},
"body": {
"a": 1
},
"cookies": {
"session": "abc"
},
"form_fields": [
{
"key": "name",
"type": "text",
"value": "widget"
},
{
"content_type": "application/octet-stream",
"key": "file",
"type": "file",
"value": "./a.bin"
}
],
"headers": {
"Authorization": "Bearer t",
"X-Zed": "z"
},
"method": "POST",
"query_params": {
"page": "2"
},
"url": "http://example.com/api"
}"#;
assert_eq!(json, expected);
let back = apply_request_json(&HurlEntry::default(), &json).unwrap();
assert_eq!(back.method, "POST");
assert_eq!(back.url, "http://example.com/api");
assert_eq!(back.basic_auth, Some(("alice".into(), "secret".into())));
assert_eq!(
back.headers,
vec![
("Authorization".to_string(), "Bearer t".to_string(), true),
("X-Zed".to_string(), "z".to_string(), true),
]
);
assert_eq!(back.form_fields.len(), 2);
assert_eq!(back.form_fields[1].kind, FormFieldKind::File);
assert_eq!(back.body_src.as_deref(), Some("{\n \"a\": 1\n}"));
}
#[test]
fn apply_request_json_tolerates_non_string_scalars_and_unknown_form_type() {
let base = HurlEntry::default();
let text = r#"{
"method": "GET",
"url": "http://x",
"headers": { "X-Count": 5 },
"form_fields": [ { "key": "k", "value": "v", "type": "weird" } ]
}"#;
let entry = apply_request_json(&base, text).unwrap();
assert_eq!(
entry.headers,
vec![("X-Count".to_string(), "5".to_string(), true)]
);
assert_eq!(entry.form_fields[0].kind, FormFieldKind::Text);
}
#[test]
fn apply_request_json_rejects_input_without_method_or_url() {
let base = HurlEntry::default();
assert!(apply_request_json(&base, "[]").is_err());
assert!(apply_request_json(&base, r#"{"url":"http://x"}"#).is_err());
}
fn me_entry() -> HurlEntry {
HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/me".into(),
headers: vec![KvRow::new("Authorization", "Bearer {{ API_TOKEN }}")],
..Default::default()
}
}
fn env_token(value: &str, resolved: bool) -> Environment {
Environment {
id: 0,
name: "e".into(),
vars: vec![EnvVar {
key: "API_TOKEN".into(),
value: value.into(),
source: ValueSource::OnePassword,
resolved,
loading: false,
original_value: value.into(),
modified: false,
user_added: false,
raw: String::new(),
}],
path: None,
git_origin: None,
}
}
fn auth_header(col: &Collection, env: Option<&Environment>) -> String {
let vars = collection_vars(env, &col.captures);
let headers = resolve_entry(&col.entries[col.selected_entry], &vars).headers;
headers
.into_iter()
.find(|(k, _)| k == "Authorization")
.unwrap()
.1
}
#[test]
fn reloading_environment_refreshes_the_sent_request() {
let col = Collection::new("c".into(), vec![me_entry()]);
let env = env_token("{{ op://Eng/demo-api/token }}", false);
assert!(
auth_header(&col, Some(&env)).contains("op://"),
"unresolved ref is sent while missing"
);
let env = env_token("real-secret", true);
assert_eq!(auth_header(&col, Some(&env)), "Bearer real-secret");
}
fn secret_var(key: &str, resolved: bool, loading: bool) -> EnvVar {
EnvVar {
key: key.into(),
value: "{{ op://x }}".into(),
source: ValueSource::OnePassword,
resolved,
loading,
original_value: "{{ op://x }}".into(),
modified: false,
user_added: false,
raw: String::new(),
}
}
fn env_with(vars: Vec<EnvVar>) -> Environment {
Environment {
id: 1,
name: "e".into(),
vars,
path: None,
git_origin: None,
}
}
#[test]
fn pending_secret_blocks_the_referencing_request() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/{{ API_TOKEN }}".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![secret_var("API_TOKEN", false, true)]);
assert_eq!(
pending_request_keys(&col, Some(&env)),
vec!["API_TOKEN".to_string()]
);
}
#[test]
fn resolved_secret_does_not_block() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/{{ API_TOKEN }}".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![secret_var("API_TOKEN", true, false)]);
assert!(pending_request_keys(&col, Some(&env)).is_empty());
}
#[test]
fn unreferenced_pending_secret_does_not_block() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/plain".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![secret_var("OTHER", false, true)]);
assert!(
pending_request_keys(&col, Some(&env)).is_empty(),
"request doesn't use the loading secret"
);
}
#[test]
fn pending_request_keys_all_checks_every_entry_not_just_the_selected_one() {
let first = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/plain".into(),
..Default::default()
};
let second = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/{{ API_TOKEN }}".into(),
..Default::default()
};
let mut col = Collection::new("c".into(), vec![first, second]);
col.selected_entry = 0; let env = env_with(vec![secret_var("API_TOKEN", false, true)]);
assert!(
pending_request_keys(&col, Some(&env)).is_empty(),
"the selected entry alone doesn't reference it"
);
assert_eq!(
pending_request_keys_all(&col, Some(&env)),
vec!["API_TOKEN".to_string()],
"Run All must check every entry, not just the selected one"
);
}
fn plain_var(key: &str, value: &str) -> EnvVar {
EnvVar {
key: key.into(),
value: value.into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: value.into(),
modified: false,
user_added: false,
raw: String::new(),
}
}
#[test]
fn a_variable_no_one_defines_is_reported() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/{{ tokn }}".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![plain_var("BASE_URL", "http://x")]);
assert_eq!(
undefined_request_keys(&col, Some(&env)),
vec!["tokn".to_string()],
"the typo is named; the variable that exists is not"
);
}
#[test]
fn a_capture_name_counts_as_defined_before_it_has_a_value() {
let login = HurlEntry {
method: "POST".into(),
url: "http://x/login".into(),
captures: vec![("token".into(), "jsonpath \"$.token\"".into())],
..Default::default()
};
let use_it = HurlEntry {
method: "GET".into(),
url: "http://x/me?t={{ token }}".into(),
..Default::default()
};
let mut col = Collection::new("c".into(), vec![login, use_it]);
col.selected_entry = 1;
assert!(
undefined_request_keys(&col, None).is_empty(),
"a captured name is defined even before the capture has run"
);
}
#[test]
fn a_pending_secret_is_defined_not_undefined() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ API_TOKEN }}".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
let env = env_with(vec![secret_var("API_TOKEN", false, true)]);
assert!(undefined_request_keys(&col, Some(&env)).is_empty());
}
#[test]
fn with_no_environment_active_every_referenced_variable_is_undefined() {
let entry = HurlEntry {
method: "GET".into(),
url: "{{ BASE_URL }}/x".into(),
headers: vec![KvRow::toggled(
"Authorization",
"Bearer {{ API_KEY }}",
true,
)],
..Default::default()
};
let col = Collection::new("c".into(), vec![entry]);
assert_eq!(
undefined_request_keys(&col, None),
vec!["API_KEY".to_string(), "BASE_URL".to_string()],
"sorted, and headers are scanned as well as the URL"
);
}
#[test]
fn undefined_request_keys_all_checks_every_entry_and_dedupes() {
let first = HurlEntry {
method: "GET".into(),
url: "{{ nope }}/a".into(),
..Default::default()
};
let second = HurlEntry {
method: "GET".into(),
url: "{{ nope }}/b".into(),
..Default::default()
};
let mut col = Collection::new("c".into(), vec![first, second]);
col.selected_entry = 0;
assert_eq!(
undefined_request_keys_all(&col, None),
vec!["nope".to_string()],
"reported once, not once per entry that uses it"
);
}
fn entry_with_generators(url: &str, rows: &[(&str, &str)]) -> HurlEntry {
let mut e = HurlEntry {
method: "GET".into(),
url: url.into(),
..Default::default()
};
e.generators = rows
.iter()
.map(|(n, x)| (n.to_string(), x.to_string()))
.collect();
e
}
#[test]
fn two_requests_computing_one_name_collide_in_a_batch_run() {
let entries = vec![
entry_with_generators("https://x/", &[("nonce", "\"first\"")]),
entry_with_generators("https://y/", &[("nonce", "\"second\"")]),
];
let blocks = expand_batch_generators(
&entries,
&HashMap::new(),
&crate::generators::SystemSource::new(),
);
assert_eq!(blocks.collisions, vec!["nonce".to_string()]);
assert_eq!(
blocks.bound.get("nonce").map(String::as_str),
Some("first"),
"the first request in the file keeps its value"
);
assert!(blocks.errors.is_empty(), "{:?}", blocks.errors);
}
#[test]
fn one_request_is_never_in_collision_with_itself() {
let entries = vec![entry_with_generators(
"https://x/",
&[("n", "\"a\""), ("n", "\"b\"")],
)];
let blocks = expand_batch_generators(
&entries,
&HashMap::new(),
&crate::generators::SystemSource::new(),
);
assert!(blocks.collisions.is_empty(), "{:?}", blocks.collisions);
}
#[test]
fn a_later_gen_block_can_read_an_earlier_one_in_batch() {
let entries = vec![
entry_with_generators("https://x/", &[("base", "\"abc\"")]),
entry_with_generators("https://y/", &[("derived", "base")]),
];
let blocks = expand_batch_generators(
&entries,
&HashMap::new(),
&crate::generators::SystemSource::new(),
);
assert_eq!(blocks.bound.get("derived").map(String::as_str), Some("abc"));
}
#[test]
fn a_failing_block_is_reported_against_its_own_request() {
let mut bad = entry_with_generators("https://y/", &[("sig", "nope()")]);
bad.title = "Sign".into();
let entries = vec![entry_with_generators("https://x/", &[("n", "uuid")]), bad];
let blocks = expand_batch_generators(
&entries,
&HashMap::new(),
&crate::generators::SystemSource::new(),
);
assert_eq!(blocks.errors.len(), 1);
assert_eq!(blocks.errors[0].0, "Sign");
}
#[test]
fn generator_collisions_names_only_what_two_requests_share() {
let col = Collection::new(
"c".into(),
vec![
entry_with_generators("https://x/", &[("nonce", "uuid"), ("ts", "timestamp")]),
entry_with_generators("https://y/", &[("nonce", "uuid")]),
],
);
assert_eq!(generator_collisions(&col), vec!["nonce".to_string()]);
}
#[test]
fn a_send_hands_back_what_its_gen_block_computed() {
let entry = entry_with_generators("https://127.0.0.1:1/", &[("nonce", "\"fixed\"")]);
let (_out, generated, _errs) =
run_resolved_entry_reporting(&entry, &HashMap::new(), None, &[]);
assert_eq!(
generated.get("nonce").map(String::as_str),
Some("fixed"),
"the connection failing doesn't unmake the value it was sent with"
);
}
#[test]
fn a_generated_name_is_not_reported_as_undefined() {
let col = Collection::new(
"c".into(),
vec![entry_with_generators(
"https://x/?n={{nonce}}",
&[("nonce", "uuid")],
)],
);
assert!(
undefined_request_keys(&col, None).is_empty(),
"the block defines nonce"
);
assert!(
generator_problems(&col, None).is_empty(),
"and the row evaluates"
);
}
#[test]
fn a_generator_row_that_cannot_evaluate_is_reported() {
let col = Collection::new(
"c".into(),
vec![entry_with_generators(
"https://x/?s={{sig}}",
&[("sig", "hmac_sha526(k, m)")],
)],
);
let problems = generator_problems(&col, None);
assert_eq!(problems.len(), 1, "one bad row, one report");
assert!(
matches!(
&problems[0],
crate::generators::GenError::UnknownFunction { name, function }
if name == "sig" && function == "hmac_sha526"
),
"the report names the row and the misspelling: {:?}",
problems[0]
);
}
#[test]
fn a_request_that_could_not_be_built_still_reports_that_it_is_over() {
let col = Collection::new(
"c".into(),
vec![entry_with_generators(
"https://x/?s={{sig}}",
&[("sig", "hmac_sha526(k, m)")],
)],
);
let state = Arc::new(Mutex::new(ApiResponse::default()));
let rx = run_collection(&col, None, state).expect("the run starts");
let update = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("a refusal is still an ending, and must be announced");
assert!(!update.ok, "a request that never left did not pass");
assert!(
update.response.error.contains("hmac_sha526"),
"and the response says which row stopped it: {:?}",
update.response.error
);
assert!(!update.response.loading, "nothing is in flight any more");
let state = Arc::new(Mutex::new(ApiResponse::default()));
let rx = run_collection(&col, None, state).expect("the run starts");
let mut cols = [col];
cols[0].entries[0].last_run = RunStatus::Running;
let mut pending = vec![rx];
for _ in 0..5 {
std::thread::sleep(std::time::Duration::from_millis(20));
drain_capture_updates(&mut pending, &mut cols);
}
assert_eq!(
cols[0].entries[0].last_run,
RunStatus::Failed,
"so the front-end stops spinning"
);
}
#[test]
fn every_failing_generator_row_is_reported() {
let col = Collection::new(
"c".into(),
vec![entry_with_generators(
"https://x/",
&[("a", "nope()"), ("b", "uuid"), ("c", "{{")],
)],
);
let problems = generator_problems(&col, None);
let rows: Vec<&str> = problems.iter().map(|e| e.row()).collect();
assert_eq!(rows, vec!["a", "c"], "b is fine and says nothing");
}
#[test]
fn generator_problems_follow_the_selection() {
let mut col = Collection::new(
"c".into(),
vec![
entry_with_generators("https://x/", &[("a", "uuid")]),
entry_with_generators("https://y/", &[("b", "nope()")]),
],
);
col.selected_entry = 0;
assert!(
generator_problems(&col, None).is_empty(),
"entry 0 is sound"
);
assert_eq!(
generator_problems_all(&col, None).len(),
1,
"Run All still sees entry 1's mistake"
);
}
#[test]
fn a_generated_name_previews_as_computed() {
let col = Collection::new(
"c".into(),
vec![entry_with_generators("https://x/", &[("nonce", "uuid")])],
);
let map = subst_map(&col, None);
let info = map.get("nonce").expect("the generator name is known");
assert_eq!(info.kind, SubstKind::Computed);
assert!(info.shown.is_none(), "no value is invented for the preview");
}
#[test]
fn collection_vars_includes_captures_overriding_env() {
let env = env_with(vec![EnvVar {
key: "access_token".into(),
value: "from-env".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "from-env".into(),
modified: false,
user_added: false,
raw: String::new(),
}]);
let mut captures = HashMap::new();
captures.insert("access_token".to_string(), "from-capture".to_string());
let vars = collection_vars(Some(&env), &captures);
assert_eq!(
vars.get("access_token").unwrap(),
"from-capture",
"a fresh capture wins over env"
);
}
#[test]
fn base_url_is_not_substituted_from_app_vars() {
let env = env_with(vec![EnvVar {
key: "API_TOKEN".into(),
value: "t".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "t".into(),
modified: false,
user_added: false,
raw: String::new(),
}]);
let vars = collection_vars(Some(&env), &HashMap::new());
assert!(
!vars.contains_key("BASE_URL"),
"BASE_URL is not injected from the app"
);
assert_eq!(
substitute("{{ BASE_URL }}/me", &vars),
"{{ BASE_URL }}/me",
"an unresolved {{ BASE_URL }} is left intact so the user notices"
);
}
#[test]
fn drain_capture_updates_routes_to_the_matching_collection() {
let c1 = Collection::new("c1".into(), vec![]);
let mut c2 = Collection::new("c2".into(), vec![HurlEntry::default()]);
c2.request_json_for = Some(0);
let target = c2.id;
let (tx, rx) = mpsc::channel();
let mut values = HashMap::new();
values.insert("access_token".to_string(), "tok".to_string());
let response = ApiResponse {
status: 200,
body: "ok".into(),
..Default::default()
};
tx.send(CaptureUpdate {
col_id: target,
entry_idx: 0,
ok: true,
values,
response,
})
.unwrap();
drop(tx);
let mut pending = vec![rx];
let mut cols = [c1, c2];
for _ in 0..3 {
drain_capture_updates(&mut pending, &mut cols);
}
assert_eq!(
cols[1].captures.get("access_token").unwrap(),
"tok",
"matching collection updated"
);
assert!(cols[0].captures.is_empty(), "other collection untouched");
assert_eq!(cols[1].request_json_for, None, "target preview invalidated");
assert_eq!(
cols[1].entries[0].last_response.as_ref().map(|r| r.status),
Some(200),
"the entry remembers its own last response"
);
}
#[test]
fn run_all_entries_returns_a_receiver_for_a_non_empty_collection() {
let e1 = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1/one".into(),
..Default::default()
};
let e2 = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1/two".into(),
..Default::default()
};
let col = Collection::new("c".into(), vec![e1, e2]);
let state = Arc::new(Mutex::new(ApiResponse::default()));
assert!(
run_all_entries(&col, None, state, false).is_some(),
"a non-empty collection must start a streaming run"
);
}
#[test]
fn run_all_entries_rejects_a_body_form_conflict_naming_the_offending_entry() {
let ok_entry = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1/ok".into(),
..Default::default()
};
let bad_entry = HurlEntry {
title: "Bad One".into(),
method: "POST".into(),
url: "http://192.0.2.1/bad".into(),
body_src: Some("{}".into()),
form_fields: vec![FormField {
key: "f".into(),
value: "v".into(),
enabled: true,
..Default::default()
}],
..Default::default()
};
let col = Collection::new("c".into(), vec![ok_entry, bad_entry]);
let state = Arc::new(Mutex::new(ApiResponse::default()));
let rx = run_all_entries(&col, None, state.clone(), false);
assert!(rx.is_none(), "must not start a run that can never be built");
let r = state.lock().unwrap();
assert!(!r.loading);
assert!(
r.error.contains("Body") && r.error.contains("Form") && r.error.contains("Bad One")
);
}
#[test]
fn subst_map_classifies_variables_by_status() {
use crate::environment::SECRET_MASK;
let env = env_with(vec![
EnvVar {
key: "HOST".into(),
value: "h".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "h".into(),
modified: false,
user_added: false,
raw: String::new(),
},
EnvVar {
key: "PORT".into(),
value: "8080".into(),
source: ValueSource::ProcessEnv,
resolved: true,
loading: false,
original_value: "8080".into(),
modified: false,
user_added: false,
raw: String::new(),
},
secret_var("TOK", false, true), secret_var("BAD", false, false), EnvVar {
key: "SECRET".into(),
value: "real".into(),
source: ValueSource::OnePassword,
resolved: true,
loading: false,
original_value: "real".into(),
modified: false,
user_added: false,
raw: String::new(),
},
]);
let mut col = Collection::new(
"c".into(),
vec![HurlEntry {
captures: vec![("token".into(), "jsonpath \"$.t\"".into())],
..Default::default()
}],
);
col.captures.insert("post_id".into(), "42".into());
let m = subst_map(&col, Some(&env));
assert!(matches!(m["HOST"].kind, SubstKind::Literal));
assert_eq!(
m["HOST"].shown.as_deref(),
Some("h"),
"literal is substituted"
);
assert!(matches!(m["PORT"].kind, SubstKind::Loaded));
assert_eq!(
m["PORT"].shown.as_deref(),
Some("8080"),
"env-var value is substituted"
);
assert!(
matches!(m["TOK"].kind, SubstKind::Pending) && m["TOK"].shown.is_none(),
"loading secret is pending, kept"
);
assert!(
matches!(m["BAD"].kind, SubstKind::Failed) && m["BAD"].shown.is_none(),
"failed secret is red, kept"
);
assert!(matches!(m["SECRET"].kind, SubstKind::Loaded));
assert_eq!(
m["SECRET"].shown.as_deref(),
Some(SECRET_MASK),
"a resolved secret is masked, not revealed"
);
assert!(
matches!(m["token"].kind, SubstKind::Failed) && m["token"].shown.is_none(),
"uninitialised capture is red, kept"
);
assert!(matches!(m["post_id"].kind, SubstKind::Loaded));
assert_eq!(
m["post_id"].shown.as_deref(),
Some("42"),
"an initialised capture is substituted"
);
}
#[test]
fn subst_display_substitutes_known_and_keeps_unavailable() {
let env = env_with(vec![
EnvVar {
key: "HOST".into(),
value: "example.test".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "example.test".into(),
modified: false,
user_added: false,
raw: String::new(),
},
secret_var("TOK", false, true), ]);
let col = Collection::new("c".into(), vec![]);
let m = subst_map(&col, Some(&env));
assert_eq!(
subst_display("{{ HOST }}/{{ TOK }}/{{ NOPE }}", &m),
"example.test/{{ TOK }}/{{ NOPE }}",
"known values are substituted; pending and unknown placeholders are kept",
);
}
#[test]
fn a_declared_parameter_supplies_its_default_when_nobody_binds_it() {
let entry = param_entry("FILE", "./samples/invoice.pdf");
let vars = HashMap::new();
let effective = effective_vars_reporting(&entry, &vars).0;
assert_eq!(
effective.get("FILE"),
Some(&"./samples/invoice.pdf".to_string()),
);
assert_eq!(
resolve_entry(&entry, &effective).form_fields[0].value,
"./samples/invoice.pdf",
"the default reaches the multipart file field as a real path",
);
}
#[test]
fn a_caller_binding_beats_the_declared_default() {
let entry = param_entry("FILE", "./samples/invoice.pdf");
let vars = HashMap::from([("FILE".to_string(), "./inbox/real.pdf".to_string())]);
let effective = effective_vars_reporting(&entry, &vars).0;
assert_eq!(effective.get("FILE"), Some(&"./inbox/real.pdf".to_string()));
}
#[test]
fn the_run_entry_never_carries_a_variable_option_to_hurl() {
let entry = param_entry("FILE", "./samples/invoice.pdf");
let vars = HashMap::from([("FILE".to_string(), "./inbox/real.pdf".to_string())]);
let mut run_entry = to_run_entry(&entry, resolve_entry(&entry, &vars));
strip_variable_options(&mut run_entry);
assert!(
run_entry.options.iter().all(|r| r.key != "variable"),
"a variable: row would override the caller inside Hurl",
);
assert!(
run_entry.options.iter().any(|r| r.key == "retry"),
"behavioural options are untouched",
);
}
#[test]
fn a_whole_file_run_strips_only_the_defaults_the_caller_bound() {
let mut entries = vec![param_entry("FILE", "./samples/invoice.pdf")];
entries[0]
.options
.push(KvRow::new("variable", "MODE=draft"));
let vars = HashMap::from([("FILE".to_string(), "./inbox/real.pdf".to_string())]);
assert!(strip_bound_variable_options(&mut entries, &vars));
let rows: Vec<&str> = entries[0]
.options
.iter()
.map(|r| r.value.as_str())
.collect();
assert_eq!(
rows,
vec!["3", "MODE=draft"],
"the bound FILE default is gone; the unbound MODE default survives",
);
}
#[test]
fn a_whole_file_run_reports_when_it_changed_nothing() {
let mut entries = vec![param_entry("FILE", "./samples/invoice.pdf")];
assert!(!strip_bound_variable_options(&mut entries, &HashMap::new()));
assert_eq!(entries[0].options.len(), 2);
}
#[test]
fn a_default_may_reference_another_variable() {
let mut entry = param_entry("SAMPLES", "{{ROOT}}/samples");
entry
.options
.push(KvRow::new("variable", "DOC={{SAMPLES}}/invoice.pdf"));
let vars = HashMap::from([("ROOT".to_string(), "/srv".to_string())]);
let effective = effective_vars_reporting(&entry, &vars).0;
assert_eq!(effective.get("SAMPLES"), Some(&"/srv/samples".to_string()));
assert_eq!(
effective.get("DOC"),
Some(&"/srv/samples/invoice.pdf".to_string()),
);
}
#[test]
fn malformed_and_disabled_parameter_rows_are_ignored() {
let mut entry = param_entry("FILE", "./samples/invoice.pdf");
entry.options = vec![
KvRow::new("variable", "no-equals-sign"),
KvRow::new("variable", "=novalue"),
KvRow::new("variable", "SPACED NAME=x"),
KvRow::toggled("variable", "OFF=x", false),
KvRow::new("VARIABLE", "SHOUTED=y"),
];
assert_eq!(
entry.variable_defaults(),
vec![("SHOUTED".to_string(), "y".to_string())],
"only the well-formed enabled row counts, and the option name is \
matched case-insensitively",
);
}
#[test]
fn a_request_without_parameters_borrows_the_callers_variables() {
let entry = me_entry();
let vars = HashMap::from([("TOKEN".to_string(), "abc".to_string())]);
assert!(matches!(
effective_vars_reporting(&entry, &vars).0,
std::borrow::Cow::Borrowed(_)
));
}
fn param_entry(name: &str, default: &str) -> HurlEntry {
HurlEntry {
title: "upload_document".into(),
method: "POST".into(),
url: "https://example.test/documents".into(),
options: vec![
KvRow::new("retry", "3"),
KvRow::new("variable", format!("{name}={default}")),
],
is_multipart: true,
form_fields: vec![FormField {
key: "file".into(),
value: format!("{{{{{name}}}}}"),
kind: FormFieldKind::File,
enabled: true,
..Default::default()
}],
..Default::default()
}
}
#[test]
fn a_request_whose_computed_value_failed_is_not_sent() {
let entry = HurlEntry {
method: "GET".to_string(),
url: "http://127.0.0.1:9/{{sig}}".to_string(),
generators: vec![("sig".to_string(), "hmac_sha526(k, m)".to_string())],
..Default::default()
};
let out = run_resolved_entry(&entry, &HashMap::new(), None, &[]);
assert!(out.entries.is_empty(), "nothing was sent");
let error = out.error.unwrap_or_default();
assert!(
error.contains("hmac_sha526"),
"and the reason names the row's fault: {error}"
);
}
struct Fixed;
impl crate::generators::GenSource for Fixed {
fn now(&self) -> (i64, u32) {
(1_700_000_000, 0)
}
fn fill_random(&self, buf: &mut [u8]) {
for (i, b) in buf.iter_mut().enumerate() {
*b = i as u8;
}
}
fn counter(&self, _name: &str) -> u64 {
1
}
}
fn recording_server(responses: usize) -> (u16, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port");
let port = listener.local_addr().unwrap().port();
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let record = std::sync::Arc::clone(&seen);
std::thread::spawn(move || {
for _ in 0..responses {
let Ok((mut sock, _)) = listener.accept() else {
return;
};
let mut buf = [0u8; 8192];
let n = sock.read(&mut buf).unwrap_or(0);
record
.lock()
.unwrap()
.push(String::from_utf8_lossy(&buf[..n]).to_string());
let _ = sock.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
);
let _ = sock.flush();
}
});
(port, seen)
}
#[test]
fn a_streaming_run_does_not_send_a_request_whose_block_failed() {
let (port, seen) = recording_server(1);
let mut e = HurlEntry::from_fields(
"Signed",
"GET",
&format!("http://127.0.0.1:{port}/orders"),
vec![KvRow::new("X-Sig", "{{sig}}")],
"",
);
e.generators = vec![("sig".to_string(), "no_such_function()".to_string())];
let entries = vec![e];
let content = collection_to_hurl(&entries);
let vars = HashMap::from([("sig".to_string(), "from-the-environment".to_string())]);
let out = crate::hurl::run::run_hurl_streaming_with(
&content,
&vars,
None,
move |i, known| {
let entry = &entries[i];
let mut merged = known.clone();
let errs = crate::generators::expand(
&entry.generators,
&mut merged,
&crate::generators::SystemSource::new(),
);
if !errs.is_empty() {
return crate::hurl::EntrySetup::Skip {
reason: "block failed".to_string(),
};
}
crate::hurl::EntrySetup::Bind(
entry
.generators
.iter()
.filter_map(|(n, _)| merged.get(n).map(|v| (n.clone(), v.clone())))
.collect(),
)
},
|_| {},
);
assert!(
seen.lock().unwrap().is_empty(),
"nothing may reach the wire: {:?}",
seen.lock().unwrap()
);
assert_eq!(out.entries.len(), 1, "the entry is still accounted for");
assert!(!out.entries[0].ok, "and it is a failure, not a pass");
assert_eq!(out.entries[0].entry_index, 0, "credited to its own request");
assert!(
out.entries[0].url.contains("/orders"),
"and says which request it was: {:?}",
out.entries[0].url
);
}
#[test]
fn a_batch_run_is_refused_when_a_block_failed() {
let mut e = HurlEntry::from_fields(
"Signed",
"GET",
"http://127.0.0.1:1/orders",
vec![KvRow::new("X-Sig", "{{sig}}")],
"",
);
e.generators = vec![("sig".to_string(), "no_such_function()".to_string())];
let vars = HashMap::from([("sig".to_string(), "from-the-environment".to_string())]);
let blocks = expand_batch_generators(&[e], &vars, &Fixed);
assert_eq!(blocks.errors.len(), 1, "the failure is reported");
assert!(
!blocks.bound.contains_key("sig"),
"and nothing is bound for the row that failed"
);
}
#[test]
fn a_computed_value_never_reaches_the_preview() {
let mut col = Collection::new("c".to_string(), vec![]);
let mut e = HurlEntry::from_fields(
"Signed",
"GET",
"http://h/a",
vec![KvRow::new("Authorization", "{{sig}}")],
"",
);
e.generators = vec![(
"sig".to_string(),
r#"hmac_sha256(API_SECRET, "canonical")"#.to_string(),
)];
col.entries.push(e);
col.captures.insert(
"sig".to_string(),
"9f8e7d6c5b4a-hmac-of-a-secret".to_string(),
);
let map = subst_map(&col, None);
assert_eq!(
map["sig"].kind,
SubstKind::Computed,
"a name the block defines stays Computed even when captured"
);
let shown = subst_display("Authorization: {{sig}}", &map);
assert_eq!(
shown, "Authorization: {{sig}}",
"the preview keeps the braces rather than showing the value"
);
}
#[test]
fn a_generator_sees_its_own_requests_parameters_in_a_run_all() {
let (port, seen) = recording_server(1);
let mut e = HurlEntry::from_fields(
"Signed",
"GET",
&format!("http://127.0.0.1:{port}/orders"),
vec![KvRow::new("X-Sig", "{{sig}}")],
"",
);
e.options = vec![KvRow::new("variable", "SAMPLE_KEY=s3cret")];
e.generators = vec![(
"sig".to_string(),
r#"hmac_sha256(SAMPLE_KEY, "m")"#.to_string(),
)];
let empty = HashMap::new();
let (vars, errs) = effective_vars_reporting(&e, &empty);
assert!(errs.is_empty(), "a single send resolves it: {errs:?}");
let expected = vars["sig"].clone();
let entries = vec![e];
let content = collection_to_hurl(&entries);
let gen_errors: std::rc::Rc<std::cell::RefCell<Vec<crate::generators::GenError>>> =
std::rc::Rc::default();
let record_errs = std::rc::Rc::clone(&gen_errors);
let out = crate::hurl::run::run_hurl_streaming_with(
&content,
&HashMap::new(),
None,
move |i, known| {
let entry = &entries[i];
let mut merged = known.clone();
record_errs.borrow_mut().extend(crate::generators::expand(
&entry.generators,
&mut merged,
&crate::generators::SystemSource::new(),
));
crate::hurl::EntrySetup::Bind(
entry
.generators
.iter()
.filter_map(|(n, _)| merged.get(n).map(|v| (n.clone(), v.clone())))
.collect(),
)
},
|_| {},
);
let sent = seen.lock().unwrap().join("\n");
assert!(
sent.contains(&format!("X-Sig: {expected}")),
"the run must send the signature a single send would (`{expected}`).\n\
errors: {:?}\nrun error: {:?}\nwire:\n{sent}",
gen_errors.borrow(),
out.error
);
}
#[test]
fn the_run_all_preflight_agrees_with_the_run() {
let mut e = HurlEntry::from_fields("Signed", "GET", "http://127.0.0.1:1/x", vec![], "");
e.options = vec![KvRow::new("variable", "SAMPLE_KEY=s3cret")];
e.generators = vec![(
"sig".to_string(),
r#"hmac_sha256(SAMPLE_KEY, "m")"#.to_string(),
)];
let col = Collection::new("c".to_string(), vec![e.clone()]);
let preflight = generator_problems_all(&col, None);
let mut merged: HashMap<String, String> = HashMap::new();
for (name, value) in e.variable_defaults() {
merged.entry(name).or_insert(value);
}
let at_run_time = crate::generators::expand(&e.generators, &mut merged, &Fixed);
assert_eq!(
preflight.len(),
at_run_time.len(),
"pre-flight: {preflight:?}\nat run time: {at_run_time:?}"
);
assert!(preflight.is_empty() && at_run_time.is_empty());
}
#[test]
fn batch_does_not_rewrite_an_earlier_requests_variable() {
let (port, seen) = recording_server(2);
let first = HurlEntry::from_fields(
"Reads TOKEN",
"GET",
&format!("http://127.0.0.1:{port}/first"),
vec![KvRow::new("X-Token", "{{TOKEN}}")],
"",
);
let mut second = HurlEntry::from_fields(
"Computes TOKEN",
"GET",
&format!("http://127.0.0.1:{port}/second"),
vec![KvRow::new("X-Token", "{{TOKEN}}")],
"",
);
second.generators = vec![(
"TOKEN".to_string(),
r#""computed-by-request-two""#.to_string(),
)];
let entries = vec![first, second];
let mut vars = HashMap::new();
vars.insert("TOKEN".to_string(), "from-the-environment".to_string());
let blocks = expand_batch_generators(&entries, &vars, &Fixed);
assert_eq!(
blocks.shadowed,
vec!["TOKEN".to_string()],
"the environment binding of TOKEN is reported as shadowed, not rewritten"
);
assert!(
!blocks.bound.contains_key("TOKEN"),
"the computed value is not bound over the environment for the whole file"
);
vars.extend(blocks.bound.clone());
let content = collection_to_hurl(&entries);
let _ = crate::hurl::run_hurl(&content, &vars, None);
let sent = seen.lock().unwrap().join("\n");
let first_req = sent.split("GET /second").next().unwrap_or("").to_string();
assert!(
first_req.contains("X-Token: from-the-environment"),
"the first request keeps the environment's TOKEN.\nwire:\n{sent}"
);
}
#[test]
fn a_blank_row_does_not_block_the_send() {
let mut e = HurlEntry::from_fields("T", "GET", "http://h/a", vec![], "");
e.generators = vec![
("nonce".to_string(), "uuid".to_string()),
(String::new(), String::new()),
];
let vars = HashMap::new();
let (_, errors) = effective_vars_reporting(&e, &vars);
assert!(
errors.is_empty(),
"a row the editor is still waiting on must not refuse the send: {errors:?}"
);
}
#[test]
fn a_counter_counts_across_sends() {
let mut e = HurlEntry::from_fields("T", "GET", "http://h/a", vec![], "");
e.generators = vec![(
"page".to_string(),
r#"counter("request-test-counter")"#.to_string(),
)];
let empty = HashMap::new();
let first = effective_vars_reporting(&e, &empty).0["page"].clone();
let second = effective_vars_reporting(&e, &empty).0["page"].clone();
assert_ne!(
first, second,
"two sends of the same request drew the same counter value ({first})"
);
}
#[test]
fn asking_whether_a_block_works_leaves_its_counter_alone() {
let mut col = Collection::new(
"c".into(),
vec![entry_with_generators(
"http://h/a",
&[("page", r#"counter("request-test-precheck")"#)],
)],
);
col.selected_entry = 0;
let empty = HashMap::new();
assert!(generator_problems(&col, None).is_empty());
assert!(generator_problems(&col, None).is_empty());
let sent = effective_vars_reporting(&col.entries[0], &empty).0["page"].clone();
assert_eq!(
sent, "1",
"the checks took the send's numbers: it drew {sent} rather than 1"
);
}
}