use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::environment::substitute;
use crate::hurl::HurlEntry;
use crate::hurl::{EntryOutcome, RunOutput};
use super::compare::{CORRECT_COLUMN, RESULT_COLUMN, TREND_COLUMN};
use super::flow::{
Binder, Element, EnvClause, FlowNode, OverrideTarget, ParallelSpec, Pattern, Producer,
ReportFlow, ReportStmt, ResponseFmt, RoleBinding, RoleRef, ShowField, UsingItem, WithItem,
};
use super::model::{ReportResult, ReportRow, Trend, Verdict};
use super::producers::{self, ProducerItem};
const DEFAULT_NO_MATCH: &str = "";
const DEFAULT_MAX_PARALLEL: usize = 8;
pub trait EntryRunner: Sync {
fn run(&self, base: &HurlEntry, vars: &HashMap<String, String>) -> RunOutput;
fn offline(&self) -> bool {
false
}
}
pub struct LiveRunner {
pub file_root: Option<PathBuf>,
}
impl EntryRunner for LiveRunner {
fn run(&self, base: &HurlEntry, vars: &HashMap<String, String>) -> RunOutput {
crate::request::run_resolved_entry(base, vars, self.file_root.as_deref(), &[])
}
}
pub struct DryRunner;
impl EntryRunner for DryRunner {
fn offline(&self) -> bool {
true
}
fn run(&self, base: &HurlEntry, _vars: &HashMap<String, String>) -> RunOutput {
RunOutput {
entries: vec![EntryOutcome {
entry_index: 0,
method: base.method.clone(),
url: base.url.clone(),
status: 0,
status_text: String::new(),
headers: Vec::new(),
body: String::new(),
raw_body: String::new(),
asserts: Vec::new(),
captures: Vec::new(),
duration_ms: 0,
setup_ms: 0,
wait_ms: 0,
download_ms: 0,
ok: true,
error: None,
}],
error: None,
}
}
}
pub enum RowEvent<'r> {
Started(&'r [(usize, usize)]),
Completed(&'r ReportRow),
}
pub type RowSink<'a> = dyn Fn(RowEvent) + Sync + 'a;
pub struct RunContext<'a> {
pub entries: &'a [HurlEntry],
pub helpers: &'a [HelperCollection],
pub base_vars: HashMap<String, String>,
pub named_envs: HashMap<String, HashMap<String, String>>,
pub root: Option<PathBuf>,
pub runner: &'a dyn EntryRunner,
pub strings: &'a crate::i18n::Strings,
pub params: super::params::ParamValues,
pub sink: Option<&'a RowSink<'a>>,
}
#[derive(Debug, Clone, Default)]
pub struct HelperCollection {
pub alias: String,
pub entries: Vec<HurlEntry>,
}
pub fn resolve_qualified<'a>(
entries: &'a [HurlEntry],
helpers: &'a [HelperCollection],
name: &str,
) -> Option<&'a HurlEntry> {
if let Some((alias, rest)) = name.split_once('/')
&& let Some(h) = helpers.iter().find(|h| h.alias == alias)
{
return resolve_title(&h.entries, rest);
}
resolve_title(entries, name)
}
pub fn resolve_title<'a>(entries: &'a [HurlEntry], name: &str) -> Option<&'a HurlEntry> {
let exact: Vec<&HurlEntry> = entries.iter().filter(|e| e.title == name).collect();
if exact.len() == 1 {
return Some(exact[0]);
}
if exact.len() > 1 {
return None;
}
let leaves: Vec<&HurlEntry> = entries
.iter()
.filter(|e| e.title.rsplit('/').next() == Some(name))
.collect();
if leaves.len() == 1 {
Some(leaves[0])
} else {
None
}
}
fn effective_params(flow: &ReportFlow, ctx: &RunContext) -> super::params::ParamValues {
super::params::effective(&flow.params(), &ctx.params)
}
pub fn run_flow(flow: &ReportFlow, ctx: &RunContext) -> ReportResult {
let mut result = run_flow_raw(flow, ctx);
finalize(&mut result, flow, ctx);
result
}
pub fn run_flow_raw(flow: &ReportFlow, ctx: &RunContext) -> ReportResult {
let mut ex = Exec::new(ctx);
ex.baseline_show = super::compare::comparison_roles_with(flow, &effective_params(flow, ctx))
.map(|r| r.baseline_show)
.unwrap_or_default();
let rows = ex.exec_block(&flow.nodes);
let no_match_marker = ex
.scopes
.first()
.and_then(|f| f.get("PRELUDE_NO_MATCH_MARKER"))
.cloned()
.unwrap_or_else(|| DEFAULT_NO_MATCH.to_string());
ReportResult {
rows,
column_order: ex.column_order,
no_match_marker,
errors: ex.errors,
timing_columns: ex.timing_columns.into_iter().collect(),
column_stats: flow.column_stats(),
column_images: flow.column_images(),
column_truths: flow.column_truths(),
column_details: flow.column_details(),
images: HashMap::new(),
verdicts: HashMap::new(),
truths: HashMap::new(),
pending: std::collections::HashSet::new(),
baseline_rows: HashMap::new(),
track_baseline: false,
trends: HashMap::new(),
}
}
pub fn finalize(result: &mut ReportResult, flow: &ReportFlow, ctx: &RunContext) {
result.track_baseline = result
.resolved_columns(&flow.header)
.iter()
.any(|c| c.truth.is_some());
if let Some(roles) = super::compare::comparison_roles_with(flow, &effective_params(flow, ctx)) {
super::compare::apply(result, &roles);
} else if let Some(rel) = flow
.header
.baseline()
.map(str::trim)
.filter(|b| !b.is_empty())
{
let path = super::producers::resolve_path(ctx.root.as_deref(), rel);
match super::baseline::Baseline::load(&path) {
Ok(baseline) => super::baseline::apply(result, &baseline),
Err(e) => result
.errors
.push(format!("baseline {}: {e}", path.display())),
}
}
resolve_truths(result, flow);
resolve_images(result, flow, ctx);
}
fn resolve_truths(result: &mut ReportResult, flow: &ReportFlow) {
let columns = result.resolved_columns(&flow.header);
let truth_columns: Vec<&super::model::OutputColumn> =
columns.iter().filter(|c| c.truth.is_some()).collect();
if truth_columns.is_empty() {
return;
}
let labels = super::labels::LabelMap::parse(&flow.header.labels());
let mut verdicts = HashMap::new();
let mut truths = HashMap::new();
let mut trends = HashMap::new();
for (r, row) in result.rows.iter().enumerate() {
let mut scope = row.cells.clone();
scope.extend(row.vars.iter().map(|(k, v)| (k.clone(), v.clone())));
for col in &truth_columns {
let Some(template) = col.truth.as_deref() else {
continue;
};
let expected = substitute(template, &scope);
let untested = expected.trim().is_empty() || expected.contains("{{");
let score = |answer: &str| {
if untested {
Verdict::Untested
} else if labels.same(&expected, answer) {
Verdict::Correct
} else {
Verdict::Incorrect
}
};
let verdict = score(&col.value(row, &result.no_match_marker));
if let Some(base) = result.baseline_rows.get(&r)
&& let Some(t) =
Trend::of(score(&col.value(base, &result.no_match_marker)), verdict)
{
trends.insert((r, col.header.clone()), t);
}
if verdict != Verdict::Untested {
truths.insert((r, col.header.clone()), expected);
}
verdicts.insert((r, col.header.clone()), verdict);
}
}
if !verdicts.is_empty() && !result.column_order.iter().any(|c| c == CORRECT_COLUMN) {
let at = usize::from(
result
.column_order
.first()
.is_some_and(|c| c == RESULT_COLUMN),
);
result.column_order.insert(at, CORRECT_COLUMN.to_string());
}
if !trends.is_empty() && !result.column_order.iter().any(|c| c == TREND_COLUMN) {
let at = result
.column_order
.iter()
.position(|c| c == CORRECT_COLUMN)
.map_or(0, |i| i + 1);
result.column_order.insert(at, TREND_COLUMN.to_string());
}
for (r, row) in result.rows.iter_mut().enumerate() {
let mut roll: Option<Verdict> = None;
for col in &truth_columns {
match verdicts.get(&(r, col.header.clone())) {
Some(Verdict::Incorrect) => {
roll = Some(Verdict::Incorrect);
break;
}
Some(Verdict::Correct) => roll = Some(Verdict::Correct),
Some(Verdict::Untested) => roll = roll.or(Some(Verdict::Untested)),
None => {}
}
}
if let Some(v) = roll {
row.cells
.insert(CORRECT_COLUMN.to_string(), v.as_str().to_string());
}
if let Some(t) = Trend::rollup(
truth_columns
.iter()
.filter_map(|col| trends.get(&(r, col.header.clone())).copied()),
) {
row.cells
.insert(TREND_COLUMN.to_string(), t.as_str().to_string());
}
}
result.verdicts = verdicts;
result.truths = truths;
result.trends = trends;
}
fn resolve_images(result: &mut ReportResult, flow: &ReportFlow, ctx: &RunContext) {
let columns = result.resolved_columns(&flow.header);
let image_columns: Vec<&super::model::OutputColumn> =
columns.iter().filter(|c| c.image.is_some()).collect();
if image_columns.is_empty() {
return;
}
let mut resolver = super::image::ImageResolver::new();
resolver.offline = ctx.runner.offline();
let mut images = HashMap::new();
for (r, row) in result.rows.iter().enumerate() {
for col in &image_columns {
let value = col.value(row, &result.no_match_marker);
if value.is_empty() || value == result.no_match_marker {
continue;
}
if let Some(img) = resolver.resolve(&value, ctx.root.as_deref()) {
images.insert((r, col.header.clone()), img);
}
}
}
result.images = images;
result.errors.extend(resolver.notes);
}
struct Exec<'a> {
ctx: &'a RunContext<'a>,
scopes: Vec<HashMap<String, String>>,
lists: HashMap<String, Producer>,
captures: HashMap<String, String>,
key_parts: Vec<String>,
path: Vec<(usize, usize)>,
target: Option<String>,
target_env: Option<HashMap<String, String>>,
broadcast: HashMap<String, String>,
column_order: Vec<String>,
timing_columns: Vec<String>,
errors: Vec<String>,
baseline_show: Vec<String>,
}
#[derive(Clone)]
struct ExecState {
scopes: Vec<HashMap<String, String>>,
lists: HashMap<String, Producer>,
captures: HashMap<String, String>,
key_parts: Vec<String>,
path: Vec<(usize, usize)>,
target: Option<String>,
target_env: Option<HashMap<String, String>>,
broadcast: HashMap<String, String>,
baseline_show: Vec<String>,
}
struct IterOut {
rows: Vec<ReportRow>,
columns: Vec<String>,
timing_columns: Vec<String>,
errors: Vec<String>,
}
impl<'a> Exec<'a> {
fn new(ctx: &'a RunContext<'a>) -> Self {
Exec {
ctx,
scopes: vec![HashMap::new()],
lists: HashMap::new(),
captures: HashMap::new(),
key_parts: Vec::new(),
path: Vec::new(),
target: None,
target_env: None,
broadcast: HashMap::new(),
column_order: Vec::new(),
timing_columns: Vec::new(),
errors: Vec::new(),
baseline_show: Vec::new(),
}
}
fn to_state(&self) -> ExecState {
ExecState {
scopes: self.scopes.clone(),
lists: self.lists.clone(),
captures: self.captures.clone(),
key_parts: self.key_parts.clone(),
path: self.path.clone(),
target: self.target.clone(),
target_env: self.target_env.clone(),
broadcast: self.broadcast.clone(),
baseline_show: self.baseline_show.clone(),
}
}
fn from_state(ctx: &'a RunContext<'a>, state: ExecState) -> Self {
Exec {
ctx,
scopes: state.scopes,
lists: state.lists,
captures: state.captures,
key_parts: state.key_parts,
path: state.path,
target: state.target,
target_env: state.target_env,
broadcast: state.broadcast,
column_order: Vec::new(),
timing_columns: Vec::new(),
errors: Vec::new(),
baseline_show: state.baseline_show,
}
}
fn parallel_degree(&self, spec: &ParallelSpec, count: usize) -> usize {
let want = spec.degree.map(|d| d as usize).unwrap_or_else(|| {
self.lookup("PRELUDE_MAX_PARALLEL")
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAX_PARALLEL)
});
want.clamp(1, count.max(1))
}
fn visible_vars(&self) -> HashMap<String, String> {
let mut m = self.ctx.base_vars.clone();
if let Some(env) = &self.target_env {
for (k, v) in env {
m.insert(k.clone(), v.clone());
}
}
for frame in &self.scopes {
for (k, v) in frame {
m.insert(k.clone(), v.clone());
}
}
m
}
fn vars_for(&self) -> HashMap<String, String> {
let mut m = self.visible_vars();
for (k, v) in &self.captures {
m.insert(k.clone(), v.clone());
}
m
}
fn lookup(&self, key: &str) -> Option<String> {
if let Some(v) = self.captures.get(key) {
return Some(v.clone());
}
for frame in self.scopes.iter().rev() {
if let Some(v) = frame.get(key) {
return Some(v.clone());
}
}
if let Some(env) = &self.target_env
&& let Some(v) = env.get(key)
{
return Some(v.clone());
}
self.ctx.base_vars.get(key).cloned()
}
fn set_var(&mut self, key: &str, value: String) {
self.scopes
.last_mut()
.expect("scope stack is never empty")
.insert(key.to_string(), value);
}
fn default_response_fmt(&self) -> ResponseFmt {
match self.lookup("PRELUDE_RESPONSE_FORMAT") {
Some(v) if v.eq_ignore_ascii_case("raw") => ResponseFmt::Raw,
_ => ResponseFmt::Pretty,
}
}
fn note_column(&mut self, key: &str) {
if !self.column_order.iter().any(|c| c == key) {
self.column_order.push(key.to_string());
}
}
fn note_timing_column(&mut self, key: &str) {
if !self.timing_columns.iter().any(|c| c == key) {
self.timing_columns.push(key.to_string());
}
}
fn exec_block(&mut self, nodes: &[FlowNode]) -> Vec<ReportRow> {
let is_leaf = !nodes
.iter()
.any(|n| matches!(n, FlowNode::ForEach { .. } | FlowNode::ForEnvs { .. }));
if is_leaf && let Some(sink) = self.ctx.sink {
sink(RowEvent::Started(&self.path));
}
let mut own: HashMap<String, String> = HashMap::new();
let mut child_rows: Vec<ReportRow> = Vec::new();
let mut has_loop = false;
for (node_index, node) in nodes.iter().enumerate() {
match node {
FlowNode::Comment(_) => {}
FlowNode::Assign { key, value } => {
let v = substitute(&unquote(value), &self.vars_for());
self.set_var(key, v);
}
FlowNode::ListDecl { name, producer } => {
self.lists.insert(name.clone(), producer.clone());
}
FlowNode::Param(p) => {
match super::params::value_for(p, &self.ctx.params, self.ctx.strings) {
Ok(raw) => {
let v = substitute(&raw, &self.vars_for());
self.set_var(&p.name, v);
}
Err(e) => self.errors.push(e),
}
}
FlowNode::Request { name, using } => {
self.run_request(name, using);
}
FlowNode::Report(stmt) => {
let cells = self.eval_report(stmt);
for (k, v) in cells {
self.note_column(&k);
own.insert(k, v);
}
}
FlowNode::ForEach {
pattern,
producer,
body,
parallel,
} => {
has_loop = true;
child_rows.extend(self.run_for_each(
node_index,
pattern,
producer,
body,
parallel.as_ref(),
&own,
));
}
FlowNode::ForEnvs {
var,
clause,
body,
parallel,
} => {
has_loop = true;
child_rows.extend(self.run_for_envs(
node_index,
var,
clause,
body,
parallel.as_ref(),
&own,
));
}
}
}
if has_loop {
for row in &mut child_rows {
for (k, v) in &own {
row.cells.entry(k.clone()).or_insert_with(|| v.clone());
}
}
child_rows
} else {
vec![self.emit_row(own)]
}
}
fn emit_row(&self, mut cells: HashMap<String, String>) -> ReportRow {
for (k, v) in &self.broadcast {
cells.entry(k.clone()).or_insert_with(|| v.clone());
}
let row = ReportRow {
cells,
vars: self.visible_vars(),
key: self.key_parts.clone(),
path: self.path.clone(),
target: self.target.clone(),
};
if let Some(sink) = self.ctx.sink {
sink(RowEvent::Completed(&row));
}
row
}
fn run_request(&mut self, name: &str, using: &[UsingItem]) -> Option<EntryOutcome> {
let base = match resolve_qualified(self.ctx.entries, self.ctx.helpers, name) {
Some(e) => e.clone(),
None => {
self.errors
.push(format!("request '{name}' could not be resolved"));
return None;
}
};
let vars = self.vars_for();
let base = match self.apply_using(name, base, using, &vars) {
Ok(base) => base,
Err(e) => {
self.errors.push(e);
return None;
}
};
let out = self.ctx.runner.run(&base, &vars);
if let Some(err) = &out.error {
self.errors.push(format!("{name}: {err}"));
}
let eo = out.entries.into_iter().next();
if let Some(eo) = &eo {
for (k, v) in &eo.captures {
self.captures.insert(k.clone(), v.clone());
}
}
eo
}
fn apply_using(
&self,
name: &str,
mut base: HurlEntry,
using: &[UsingItem],
vars: &HashMap<String, String>,
) -> Result<HurlEntry, String> {
for item in using {
match item {
UsingItem::Require(param) => {
if !base.declares_variable(param) {
let declared = base.variable_defaults();
let declared = if declared.is_empty() {
"none".to_string()
} else {
declared
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
};
return Err(format!(
"request '{name}' does not declare a parameter '{param}' \
(declares: {declared}) — add `variable: {param}=…` to its \
[Options] section"
));
}
}
UsingItem::Override { target, value } => {
let value = substitute(value, vars);
apply_override(&mut base, target, value)
.map_err(|e| format!("request '{name}': {e}"))?;
}
}
}
Ok(base)
}
fn eval_report(&mut self, stmt: &ReportStmt) -> Vec<(String, String)> {
match stmt {
ReportStmt::Vars(vars) => vars
.iter()
.map(|v| (v.clone(), self.lookup(v).unwrap_or_default()))
.collect(),
ReportStmt::VarAs { var, name, .. } => {
vec![(name.clone(), self.lookup(var).unwrap_or_default())]
}
ReportStmt::Computed { template, name, .. } => {
let value = substitute(template, &self.vars_for());
vec![(name.clone(), value)]
}
ReportStmt::Request {
name,
alias,
using,
response_fmt,
show,
hide,
with,
} => self.eval_report_request(
name,
alias.as_deref(),
using,
*response_fmt,
show,
hide,
with,
),
}
}
fn eval_report_request(
&mut self,
name: &str,
alias: Option<&str>,
using: &[UsingItem],
response_fmt: Option<ResponseFmt>,
show: &[ShowField],
hide: &[String],
with: &[WithItem],
) -> Vec<(String, String)> {
let alias = alias
.map(str::to_string)
.unwrap_or_else(|| leaf(name).to_string());
let mut cells: Vec<(String, String)> = Vec::new();
let base = match resolve_qualified(self.ctx.entries, self.ctx.helpers, name) {
Some(e) => e.clone(),
None => {
self.errors
.push(format!("request '{name}' could not be resolved"));
cells.push((
format!("{alias}.Error"),
format!("unresolved request '{name}'"),
));
return cells;
}
};
let vars = self.vars_for();
let base = match self.apply_using(name, base, using, &vars) {
Ok(base) => base,
Err(e) => {
self.errors.push(e.clone());
cells.push((format!("{alias}.Error"), e));
return cells;
}
};
let out = self.ctx.runner.run(&base, &vars);
let eo = match out.entries.into_iter().next() {
Some(eo) => eo,
None => {
let err = out
.error
.unwrap_or_else(|| "request produced no response".into());
self.errors.push(format!("{name}: {err}"));
cells.push((format!("{alias}.Error"), err));
return cells;
}
};
for (k, v) in &eo.captures {
self.captures.insert(k.clone(), v.clone());
}
let with_fmt = with.iter().find_map(|w| match w {
WithItem::ResponseFmt(f) => Some(*f),
_ => None,
});
let fmt = response_fmt
.or(with_fmt)
.unwrap_or_else(|| self.default_response_fmt());
let response = match fmt {
ResponseFmt::Raw => eo.raw_body.clone(),
ResponseFmt::Pretty => eo.body.clone(),
};
cells.push((format!("{alias}.HttpStatus"), eo.status.to_string()));
cells.push((format!("{alias}.Time"), eo.duration_ms.to_string()));
cells.push((format!("{alias}.TimeSetup"), eo.setup_ms.to_string()));
cells.push((format!("{alias}.TimeWait"), eo.wait_ms.to_string()));
cells.push((format!("{alias}.TimeDownload"), eo.download_ms.to_string()));
cells.push((format!("{alias}.Asserts"), asserts_summary(&eo)));
cells.push((
format!("{alias}.Error"),
eo.error.clone().unwrap_or_default(),
));
cells.push((format!("{alias}.Response"), response.clone()));
let mut fields: Vec<(String, String)> = base.reports.clone();
for w in with {
if let WithItem::Field { name, query, .. } = w {
fields.retain(|(n, _)| n != name);
fields.push((name.clone(), query.clone()));
}
}
for (fname, query) in fields {
let query = query.trim();
let value = match query {
"HttpStatus" => eo.status.to_string(),
"Time" => eo.duration_ms.to_string(),
"TimeSetup" => eo.setup_ms.to_string(),
"TimeWait" => eo.wait_ms.to_string(),
"TimeDownload" => eo.download_ms.to_string(),
"Asserts" => asserts_summary(&eo),
"Error" => eo.error.clone().unwrap_or_default(),
"Response" => response.clone(),
q => eval_field(q, &eo).unwrap_or_default(),
};
let key = format!("{alias}.{fname}");
if TIMING_INTRINSIC_FIELDS.contains(&query) {
self.note_timing_column(&key);
}
cells.push((key, value));
}
let has_declared =
!base.reports.is_empty() || with.iter().any(|w| matches!(w, WithItem::Field { .. }));
if has_declared {
cells.retain(|(k, _)| {
let suffix = k.strip_prefix(&format!("{alias}.")).unwrap_or(k.as_str());
!INTRINSIC_FIELDS.contains(&suffix)
|| show.iter().any(|s| s.name() == suffix)
|| self.baseline_show.iter().any(|s| s == suffix)
});
} else {
cells.retain(|(k, _)| {
let suffix = k.strip_prefix(&format!("{alias}.")).unwrap_or(k.as_str());
!OPT_IN_INTRINSIC_FIELDS.contains(&suffix)
|| show.iter().any(|s| s.name() == suffix)
|| self.baseline_show.iter().any(|s| s == suffix)
});
}
if !hide.is_empty() {
cells.retain(|(k, _)| {
let suffix = k.strip_prefix(&format!("{alias}.")).unwrap_or(k.as_str());
!hide.iter().any(|h| h == suffix)
});
}
cells
}
fn run_for_each(
&mut self,
node_index: usize,
pattern: &Pattern,
producer: &Producer,
body: &[FlowNode],
parallel: Option<&ParallelSpec>,
inherited: &HashMap<String, String>,
) -> Vec<ReportRow> {
let items = match self.expand_producer(producer) {
Ok(t) => t,
Err(e) => {
self.errors.push(e);
return Vec::new();
}
};
let mut seed = self.to_state();
for (k, v) in inherited {
seed.broadcast.insert(k.clone(), v.clone());
}
let ctx = self.ctx;
let run_one = |i: usize| -> IterOut {
let item = &items[i];
let mut sub = Exec::from_state(ctx, seed.clone());
sub.path.push((node_index, i));
sub.check_arity(pattern, item);
sub.scopes.push(HashMap::new());
sub.bind_pattern(pattern, &item.values);
for (k, v) in &item.named {
sub.set_var(k, v.clone());
}
let rows = sub.exec_block(body);
IterOut {
rows,
columns: sub.column_order,
timing_columns: sub.timing_columns,
errors: sub.errors,
}
};
self.run_iterations(items.len(), parallel, run_one)
}
fn run_for_envs(
&mut self,
node_index: usize,
var: &str,
clause: &EnvClause,
body: &[FlowNode],
parallel: Option<&ParallelSpec>,
inherited: &HashMap<String, String>,
) -> Vec<ReportRow> {
let mut live: Vec<String> = Vec::new();
let mut files: Vec<String> = Vec::new();
let vars = self.vars_for();
let resolve = |s: &String| crate::environment::substitute(s, &vars);
match clause {
EnvClause::Plain(names) => live = names.iter().map(resolve).collect(),
EnvClause::Roles {
baseline,
comparisons,
..
} => {
for r in baseline.iter().chain(comparisons) {
match r {
RoleRef::Env(n) => live.push(resolve(n)),
RoleRef::File(p) => files.push(resolve(p)),
}
}
}
}
let mut seed = self.to_state();
for (k, v) in inherited {
seed.broadcast.insert(k.clone(), v.clone());
}
let ctx = self.ctx;
let run_one = |i: usize| -> IterOut {
let name = &live[i];
let mut sub = Exec::from_state(ctx, seed.clone());
sub.path.push((node_index, i));
sub.target = Some(name.clone());
sub.target_env = ctx.named_envs.get(name).cloned();
if sub.target_env.is_none() {
sub.errors
.push(format!("environment '{name}' is not loaded"));
}
sub.scopes.push(HashMap::new());
sub.set_var(var, name.clone());
let rows = sub.exec_block(body);
IterOut {
rows,
columns: sub.column_order,
timing_columns: sub.timing_columns,
errors: sub.errors,
}
};
let mut rows = self.run_iterations(live.len(), parallel, run_one);
for (fi, rel) in files.iter().enumerate() {
let path = super::producers::resolve_path(self.ctx.root.as_deref(), rel);
match super::baseline::Baseline::load(&path) {
Ok(snapshot) => {
for (ri, br) in snapshot.rows.iter().enumerate() {
let mut row = br.to_row();
row.target = Some(rel.clone());
let mut p = self.path.clone();
p.push((node_index, live.len() + fi));
p.push((node_index, ri));
row.path = p;
for k in row.cells.keys().cloned().collect::<Vec<_>>() {
self.note_column(&k);
}
if let Some(sink) = self.ctx.sink {
sink(RowEvent::Completed(&row));
}
rows.push(row);
}
}
Err(e) => self
.errors
.push(format!("baseline {}: {e}", path.display())),
}
}
rows
}
fn run_iterations<F>(
&mut self,
count: usize,
parallel: Option<&ParallelSpec>,
run_one: F,
) -> Vec<ReportRow>
where
F: Fn(usize) -> IterOut + Sync,
{
let outs: Vec<IterOut> = match parallel {
Some(spec) if count > 1 => {
let degree = self.parallel_degree(spec, count);
let next = AtomicUsize::new(0);
let slots: Vec<Mutex<Option<IterOut>>> =
(0..count).map(|_| Mutex::new(None)).collect();
std::thread::scope(|s| {
for _ in 0..degree {
s.spawn(|| {
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
if i >= count {
break;
}
let out = run_one(i);
*slots[i].lock().unwrap() = Some(out);
}
});
}
});
slots
.into_iter()
.map(|m| m.into_inner().unwrap().expect("every slot is filled"))
.collect()
}
_ => (0..count).map(&run_one).collect(),
};
let mut rows = Vec::new();
for out in outs {
for c in &out.columns {
self.note_column(c);
}
for c in &out.timing_columns {
self.note_timing_column(c);
}
self.errors.extend(out.errors);
rows.extend(out.rows);
}
rows
}
fn check_arity(&mut self, pattern: &Pattern, item: &ProducerItem) {
let want = pattern.binders.len();
let got = item.values.len();
if want == 1 && !item.named.is_empty() {
return;
}
let ok = if pattern.rest {
want <= got
} else {
want == got
};
if !ok {
self.errors.push(format!(
"pattern binds {want} value(s) but the item has {got}"
));
}
}
fn bind_pattern(&mut self, pattern: &Pattern, tuple: &[String]) {
for (i, binder) in pattern.binders.iter().enumerate() {
let value = tuple.get(i).cloned().unwrap_or_default();
if let Binder::Named(n) = binder {
self.set_var(n, value.clone());
self.key_parts.push(value);
}
}
}
fn expand_producer(&self, producer: &Producer) -> Result<Vec<ProducerItem>, String> {
let root = self.ctx.root.as_deref();
match producer {
Producer::List(elements) => Ok(elements
.iter()
.map(|el| match el {
Element::Scalar(s) => ProducerItem::scalar(self.subst_unquoted(s)),
Element::Tuple(parts) => ProducerItem {
values: parts.iter().map(|p| self.subst_unquoted(p)).collect(),
named: Vec::new(),
},
})
.collect()),
Producer::Named(name) => {
let inner = self
.lists
.get(name)
.ok_or_else(|| format!("list '{name}' is not declared"))?
.clone();
self.expand_producer(&inner)
}
Producer::Files { dir, glob } => {
let dir = producers::resolve_path(root, &self.subst_unquoted(dir));
let glob = glob.as_ref().map(|g| self.subst_unquoted(g));
Ok(producers::list_files(&dir, glob.as_deref())?
.into_iter()
.map(|p| ProducerItem::scalar(p.to_string_lossy().into_owned()))
.collect())
}
Producer::Folders { dir, glob, roles } => {
let dir = producers::resolve_path(root, &self.subst_unquoted(dir));
let glob = glob.as_ref().map(|g| self.subst_unquoted(g));
let roles: Vec<RoleBinding> = roles
.iter()
.map(|r| RoleBinding {
name: r.name.clone(),
glob: self.subst_unquoted(&r.glob),
optional: r.optional,
})
.collect();
let on_missing = if glob.as_deref().is_some_and(|g| g.contains("**")) {
producers::Missing::Skip
} else {
producers::Missing::Error
};
let mut items = Vec::new();
for folder in producers::list_folders(&dir, glob.as_deref())? {
let Some(named) = producers::folder_roles(&folder, &roles, on_missing)? else {
continue;
};
items.push(ProducerItem {
values: vec![folder.to_string_lossy().into_owned()],
named,
});
}
Ok(items)
}
Producer::Tuples { path } => {
let path = producers::resolve_path(root, &self.subst_unquoted(path));
producers::read_tuples(&path)
}
Producer::Zip(parts) => {
let lists: Result<Vec<Vec<ProducerItem>>, String> =
parts.iter().map(|p| self.expand_producer(p)).collect();
producers::zip_items(lists?)
}
Producer::Concat(parts) => {
let lists: Result<Vec<Vec<ProducerItem>>, String> =
parts.iter().map(|p| self.expand_producer(p)).collect();
producers::concat_items(lists?)
}
}
}
fn subst_unquoted(&self, s: &str) -> String {
substitute(&unquote(s), &self.vars_for())
}
}
fn apply_override(
entry: &mut HurlEntry,
target: &OverrideTarget,
value: String,
) -> Result<(), String> {
fn upsert(rows: &mut Vec<crate::hurl::KvRow>, key: &str, value: String) {
match rows.iter_mut().find(|r| r.key == key) {
Some(row) => {
row.value = value;
row.enabled = true;
}
None => rows.push(crate::hurl::KvRow::new(key, value)),
}
}
match target {
OverrideTarget::Url => entry.url = value,
OverrideTarget::Body => entry.body_src = Some(value),
OverrideTarget::Header(k) => upsert(&mut entry.headers, k, value),
OverrideTarget::Query(k) => upsert(&mut entry.queries, k, value),
OverrideTarget::Cookie(k) => upsert(&mut entry.cookies, k, value),
OverrideTarget::Option(k) => upsert(&mut entry.options, k, value),
OverrideTarget::Form(k) | OverrideTarget::Multipart(k) => {
match entry.form_fields.iter_mut().find(|f| &f.key == k) {
Some(field) => {
if field.kind.is_multipart()
&& let Some(spec) = value.strip_prefix("file,")
{
let parsed = crate::hurl::parse_file_form_value(k, spec);
field.value = parsed.value;
if parsed.content_type.is_some() {
field.content_type = parsed.content_type;
}
} else {
field.value = value;
}
field.enabled = true;
}
None => {
let known: Vec<&str> =
entry.form_fields.iter().map(|f| f.key.as_str()).collect();
let known = if known.is_empty() {
"it has no form fields".to_string()
} else {
format!("it has: {}", known.join(", "))
};
let section = match target {
OverrideTarget::Form(_) => "form",
_ => "multipart",
};
return Err(format!("has no {section} field '{k}' ({known})"));
}
}
}
OverrideTarget::BasicAuthUser => {
let (_, pass) = entry.basic_auth.clone().unwrap_or_default();
entry.basic_auth = Some((value, pass));
}
OverrideTarget::BasicAuthPass => {
let (user, _) = entry.basic_auth.clone().unwrap_or_default();
entry.basic_auth = Some((user, value));
}
}
Ok(())
}
fn leaf(name: &str) -> &str {
name.rsplit('/').next().unwrap_or(name)
}
fn unquote(s: &str) -> String {
let t = s.trim();
if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
t[1..t.len() - 1].to_string()
} else {
s.to_string()
}
}
fn asserts_summary(eo: &EntryOutcome) -> String {
let total = eo.asserts.len();
if total == 0 {
return String::new();
}
let passed = eo.asserts.iter().filter(|a| a.passed).count();
format!("{passed}/{total}")
}
pub(crate) const OPT_IN_INTRINSIC_FIELDS: [&str; 3] = ["TimeSetup", "TimeWait", "TimeDownload"];
pub(crate) const TIMING_INTRINSIC_FIELDS: [&str; 4] =
["Time", "TimeSetup", "TimeWait", "TimeDownload"];
pub(crate) const INTRINSIC_FIELDS: [&str; 8] = [
"HttpStatus",
"Time",
"TimeSetup",
"TimeWait",
"TimeDownload",
"Asserts",
"Error",
"Response",
];
fn eval_field(query: &str, eo: &EntryOutcome) -> Option<String> {
let query = query.trim();
let (kind, rest) = match query.split_once(char::is_whitespace) {
Some((k, r)) => (k, r.trim()),
None => (query, ""),
};
match kind {
"status" => Some(eo.status.to_string()),
"body" => Some(eo.raw_body.clone()),
"header" => {
let name = string_arg(rest)?;
eo.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(&name))
.map(|(_, v)| v.clone())
}
"jsonpath" => {
let path = string_arg(rest)?;
let root: serde_json::Value = serde_json::from_str(&eo.raw_body).ok()?;
json_path_get(&root, &path).map(json_value_to_string)
}
"regex" => {
let pat = string_arg(rest)?;
let re = regex::Regex::new(&pat).ok()?;
let caps = re.captures(&eo.raw_body)?;
caps.get(1)
.or_else(|| caps.get(0))
.map(|m| m.as_str().to_string())
}
_ => None,
}
}
fn string_arg(s: &str) -> Option<String> {
let s = s.trim();
let inner = s.strip_prefix('"')?.strip_suffix('"')?;
Some(inner.replace("\\\"", "\"").replace("\\\\", "\\"))
}
fn json_path_get(root: &serde_json::Value, path: &str) -> Option<serde_json::Value> {
let rest = path.strip_prefix('$')?;
let mut cur: Vec<&serde_json::Value> = vec![root];
let mut filtered = false;
let bytes = rest.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'.' => {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'[' {
i += 1;
}
let key = &rest[start..i];
if key.is_empty() {
return None;
}
cur = cur.iter().filter_map(|v| v.get(key)).collect();
}
b'[' => {
let end = rest[i..].find(']')? + i;
let inner = rest[i + 1..end].trim();
if let Some(pred) = inner.strip_prefix("?(").and_then(|s| s.strip_suffix(')')) {
let pred = parse_filter(pred)?;
cur = cur
.iter()
.flat_map(|v| v.as_array().map(|a| a.iter()).into_iter().flatten())
.filter(|el| pred.matches(el))
.collect();
filtered = true;
} else if let Some(k) = inner
.strip_prefix('"')
.and_then(|k| k.strip_suffix('"'))
.or_else(|| inner.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
{
cur = cur.iter().filter_map(|v| v.get(k)).collect();
} else {
let idx: usize = inner.parse().ok()?;
cur = cur.iter().filter_map(|v| v.get(idx)).collect();
}
i = end + 1;
}
_ => return None,
}
if !filtered && cur.is_empty() {
return None;
}
}
if filtered {
return match cur.len() {
0 => None,
1 => Some(cur[0].clone()),
_ => Some(serde_json::Value::Array(cur.into_iter().cloned().collect())),
};
}
cur.first().map(|v| (*v).clone())
}
struct JsonFilter {
path: String,
negated: bool,
value: serde_json::Value,
}
impl JsonFilter {
fn matches(&self, el: &serde_json::Value) -> bool {
let found = json_path_get(el, &self.path);
let eq = found.as_ref() == Some(&self.value);
eq != self.negated
}
}
fn parse_filter(pred: &str) -> Option<JsonFilter> {
let (lhs, rhs, negated) = match pred.split_once("==") {
Some((l, r)) => (l, r, false),
None => {
let (l, r) = pred.split_once("!=")?;
(l, r, true)
}
};
let path = lhs.trim().strip_prefix('@')?.trim();
if path.is_empty() {
return None;
}
let rhs = rhs.trim();
let value = if let Some(s) = rhs
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.or_else(|| rhs.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
{
serde_json::Value::String(s.to_string())
} else {
serde_json::from_str(rhs).ok()?
};
Some(JsonFilter {
path: format!("${path}"),
negated,
value,
})
}
fn json_value_to_string(v: serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s,
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hurl::AssertOutcome;
use crate::report::model::StatKind;
use crate::report::parse_flow;
use std::sync::Mutex;
#[derive(Clone, Default)]
struct Canned {
status: u16,
raw_body: String,
pretty_body: String,
captures: Vec<(String, String)>,
headers: Vec<(String, String)>,
asserts: Vec<(bool,)>,
duration_ms: u64,
setup_ms: u64,
wait_ms: u64,
download_ms: u64,
error: Option<String>,
}
struct Fake {
canned: HashMap<String, Canned>,
calls: Mutex<Vec<(String, HashMap<String, String>)>>,
sent: Mutex<Vec<HurlEntry>>,
active: AtomicUsize,
max_active: AtomicUsize,
delay_ms: u64,
}
impl Fake {
fn new(canned: &[(&str, Canned)]) -> Self {
Fake {
canned: canned
.iter()
.map(|(k, c)| (k.to_string(), c.clone()))
.collect(),
calls: Mutex::new(Vec::new()),
sent: Mutex::new(Vec::new()),
active: AtomicUsize::new(0),
max_active: AtomicUsize::new(0),
delay_ms: 0,
}
}
fn with_delay(mut self, ms: u64) -> Self {
self.delay_ms = ms;
self
}
fn call_vars(&self, title: &str) -> HashMap<String, String> {
self.calls
.lock()
.unwrap()
.iter()
.find(|(t, _)| t == title)
.map(|(_, v)| v.clone())
.unwrap_or_default()
}
fn sent_entry(&self, title: &str) -> Option<HurlEntry> {
self.sent
.lock()
.unwrap()
.iter()
.find(|e| e.title == title)
.cloned()
}
fn call_count(&self) -> usize {
self.calls.lock().unwrap().len()
}
fn peak_concurrency(&self) -> usize {
self.max_active.load(Ordering::Relaxed)
}
}
impl EntryRunner for Fake {
fn run(&self, base: &HurlEntry, vars: &HashMap<String, String>) -> RunOutput {
let now = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.max_active.fetch_max(now, Ordering::SeqCst);
if self.delay_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(self.delay_ms));
}
self.calls
.lock()
.unwrap()
.push((base.title.clone(), vars.clone()));
self.sent.lock().unwrap().push(base.clone());
let c = self.canned.get(&base.title).cloned().unwrap_or_default();
let eo = EntryOutcome {
entry_index: 0,
method: base.method.clone(),
url: base.url.clone(),
status: c.status,
status_text: String::new(),
headers: c.headers,
body: if c.pretty_body.is_empty() {
c.raw_body.clone()
} else {
c.pretty_body
},
raw_body: c.raw_body,
asserts: c
.asserts
.iter()
.map(|(p,)| AssertOutcome {
expr: String::new(),
passed: *p,
detail: String::new(),
})
.collect(),
captures: c.captures,
duration_ms: c.duration_ms,
setup_ms: c.setup_ms,
wait_ms: c.wait_ms,
download_ms: c.download_ms,
ok: c.error.is_none(),
error: c.error.clone(),
};
self.active.fetch_sub(1, Ordering::SeqCst);
RunOutput {
entries: vec![eo],
error: c.error,
}
}
}
fn entry(title: &str, reports: &[(&str, &str)]) -> HurlEntry {
HurlEntry {
title: title.to_string(),
method: "GET".into(),
url: "http://x".into(),
reports: reports
.iter()
.map(|(n, q)| (n.to_string(), q.to_string()))
.collect(),
..Default::default()
}
}
fn run(
src: &str,
entries: &[HurlEntry],
base_vars: &[(&str, &str)],
named_envs: &[(&str, &[(&str, &str)])],
fake: &Fake,
) -> ReportResult {
let flow = parse_flow(src).expect("flow parses");
let ctx = RunContext {
entries,
helpers: &[],
base_vars: base_vars
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
named_envs: named_envs
.iter()
.map(|(name, kvs)| {
(
name.to_string(),
kvs.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
)
})
.collect(),
root: None,
runner: fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
run_flow(&flow, &ctx)
}
fn run_envs_with_params(
src: &str,
entries: &[HurlEntry],
named_envs: &[(&str, &[(&str, &str)])],
params: &[(&str, &str)],
fake: &Fake,
) -> ReportResult {
let flow = parse_flow(src).expect("flow parses");
let ctx = RunContext {
entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: named_envs
.iter()
.map(|(name, kvs)| {
(
name.to_string(),
kvs.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
)
})
.collect(),
root: None,
runner: fake,
strings: crate::i18n::Strings::english(),
params: params
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
sink: None,
};
run_flow(&flow, &ctx)
}
#[test]
fn a_comparison_can_be_pointed_at_its_stacks_by_parameter() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"a\":1}".into(),
duration_ms: 7,
..Default::default()
},
)]);
let entries = [entry("r", &[])];
let src = "PARAM BASELINE_ENV = \"prod\"\nPARAM COMPARE_ENV = \"staging\"\n\
FOR T IN ENVS BASELINE(\"{{BASELINE_ENV}}\"), COMPARISON(\"{{COMPARE_ENV}}\")\n\
REPORT REQUEST r AS proc\nEND\n";
let envs = [
("prod", &[][..]),
("staging", &[][..]),
("prod-eu", &[][..]),
("staging-eu", &[][..]),
];
let res = run_envs_with_params(src, &entries, &envs, &[], &fake);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(res.rows.len(), 1);
assert_eq!(res.rows[0].target.as_deref(), Some("staging"));
let res = run_envs_with_params(
src,
&entries,
&envs,
&[("BASELINE_ENV", "prod-eu"), ("COMPARE_ENV", "staging-eu")],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(res.rows.len(), 1);
assert_eq!(
res.rows[0].target.as_deref(),
Some("staging-eu"),
"the collapse kept the candidate row for the chosen comparison env"
);
}
#[test]
fn a_satisfied_requirement_sends_the_request() {
let mut entries = vec![entry("upload", &[])];
entries[0].options = vec![crate::hurl::KvRow::new("variable", "FILE=./sample.pdf")];
let fake = Fake::new(&[("upload", Canned::default())]);
let res = run(
"# collection: c\n\nFILE=./real.pdf\nREQUEST upload USING(FILE)\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(fake.call_count(), 1);
assert_eq!(
fake.call_vars("upload").get("FILE").map(String::as_str),
Some("./real.pdf")
);
}
#[test]
fn an_unmet_requirement_stops_the_send() {
let entries = vec![entry("upload", &[])];
let fake = Fake::new(&[("upload", Canned::default())]);
let res = run(
"# collection: c\n\nFILE=./real.pdf\nREQUEST upload USING(FILE)\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(fake.call_count(), 0, "the request must not be sent");
assert_eq!(res.errors.len(), 1);
assert!(
res.errors[0].contains("does not declare a parameter 'FILE'"),
"{:?}",
res.errors
);
}
#[test]
fn an_unmet_requirement_reports_an_error_cell() {
let entries = vec![entry("upload", &[])];
let fake = Fake::new(&[("upload", Canned::default())]);
let res = run(
"# collection: c\n\nREPORT REQUEST upload USING(FILE)\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(res.rows.len(), 1);
assert!(
res.rows[0].cells["upload.Error"].contains("does not declare a parameter 'FILE'"),
"{:?}",
res.rows[0].cells
);
}
#[test]
fn an_override_patches_the_entry_before_it_is_sent() {
let mut entries = vec![entry("upload", &[])];
entries[0].form_fields = vec![crate::hurl::FormField {
key: "file".into(),
value: "./sample.pdf".into(),
kind: crate::hurl::FormFieldKind::File,
enabled: true,
..Default::default()
}];
let fake = Fake::new(&[("upload", Canned::default())]);
let res = run(
"# collection: c\n\nDOC=./real.pdf\nREQUEST upload USING(multipart.file = \"{{DOC}}\", header.X-Run = \"7\")\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
let sent = fake.sent_entry("upload").expect("sent");
assert_eq!(sent.form_fields[0].value, "./real.pdf");
assert_eq!(
sent.headers
.iter()
.find(|h| h.key == "X-Run")
.map(|h| h.value.as_str()),
Some("7"),
"a header the request never had is added",
);
}
#[test]
fn a_file_override_may_be_written_the_way_the_collection_spells_it() {
let mut entries = vec![entry("upload", &[])];
entries[0].form_fields = vec![crate::hurl::FormField {
key: "clip".into(),
value: String::new(),
kind: crate::hurl::FormFieldKind::File,
content_type: Some("video/webm".into()),
enabled: false,
..Default::default()
}];
let fake = Fake::new(&[("upload", Canned::default())]);
let res = run(
"# collection: c\n\nV=/tmp/a b.webm\nREQUEST upload USING(multipart.clip = \"file,{{V}};video/mp4\")\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
let sent = fake.sent_entry("upload").expect("sent");
assert_eq!(
sent.form_fields[0].value, "/tmp/a b.webm",
"the path is the path, escaping and all"
);
assert_eq!(
sent.form_fields[0].content_type.as_deref(),
Some("video/mp4"),
"and a content type the spelling names replaces the row's"
);
assert!(
sent.form_fields[0].enabled,
"overriding a row switches it on"
);
}
#[test]
fn a_bare_path_override_keeps_the_rows_own_content_type() {
let mut entries = vec![entry("upload", &[])];
entries[0].form_fields = vec![crate::hurl::FormField {
key: "clip".into(),
value: String::new(),
kind: crate::hurl::FormFieldKind::File,
content_type: Some("video/webm".into()),
enabled: false,
..Default::default()
}];
let fake = Fake::new(&[("upload", Canned::default())]);
run(
"# collection: c\n\nREQUEST upload USING(multipart.clip = \"/tmp/a.webm\")\n",
&entries,
&[],
&[],
&fake,
);
let sent = fake.sent_entry("upload").expect("sent");
assert_eq!(sent.form_fields[0].value, "/tmp/a.webm");
assert_eq!(
sent.form_fields[0].content_type.as_deref(),
Some("video/webm")
);
}
#[test]
fn an_override_does_not_leak_into_the_collection() {
let mut entries = vec![entry("upload", &[])];
entries[0].url = "http://x/original".into();
let fake = Fake::new(&[("upload", Canned::default())]);
run(
"# collection: c\n\nREQUEST upload USING(url = \"http://x/patched\")\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(entries[0].url, "http://x/original");
assert_eq!(fake.sent_entry("upload").unwrap().url, "http://x/patched");
}
#[test]
fn an_override_of_a_missing_form_field_stops_the_send() {
let entries = vec![entry("upload", &[])];
let fake = Fake::new(&[("upload", Canned::default())]);
let res = run(
"# collection: c\n\nREQUEST upload USING(multipart.fil = \"x\")\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(fake.call_count(), 0);
assert!(
res.errors[0].contains("has no multipart field 'fil'"),
"{:?}",
res.errors
);
}
fn run_with_params(
src: &str,
entries: &[HurlEntry],
params: &[(&str, &str)],
fake: &Fake,
) -> ReportResult {
let flow = parse_flow(src).expect("flow parses");
let ctx = RunContext {
entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: fake,
strings: crate::i18n::Strings::english(),
params: params
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
sink: None,
};
run_flow(&flow, &ctx)
}
fn param_entries() -> Vec<HurlEntry> {
vec![HurlEntry {
title: "get".into(),
method: "GET".into(),
url: "http://x/{{TARGET}}".into(),
reports: vec![("Url".into(), "url".into())],
..Default::default()
}]
}
#[test]
fn a_chosen_parameter_value_reaches_the_request() {
let entries = param_entries();
let fake = Fake::new(&[(
"get",
Canned {
status: 200,
..Default::default()
},
)]);
let src = "PARAM ENV TARGET = \"staging\"\nREPORT TARGET AS Target\nREPORT REQUEST get\n";
let on_defaults = run_with_params(src, &entries, &[], &fake);
assert!(on_defaults.errors.is_empty(), "{:?}", on_defaults.errors);
assert_eq!(on_defaults.rows[0].cells.get("Target").unwrap(), "staging");
let overridden = run_with_params(src, &entries, &[("TARGET", "prod")], &fake);
assert_eq!(overridden.rows[0].cells.get("Target").unwrap(), "prod");
}
#[test]
fn a_missing_required_parameter_is_a_run_error() {
let entries = param_entries();
let fake = Fake::new(&[(
"get",
Canned {
status: 200,
..Default::default()
},
)]);
let src = "PARAM TEXT TARGET\nREPORT REQUEST get\n";
let result = run_with_params(src, &entries, &[], &fake);
assert_eq!(result.errors.len(), 1, "{:?}", result.errors);
assert!(result.errors[0].contains("TARGET"), "{:?}", result.errors);
let supplied = run_with_params(src, &entries, &[("TARGET", "au")], &fake);
assert!(supplied.errors.is_empty(), "{:?}", supplied.errors);
}
#[test]
fn a_supplied_value_that_breaks_its_own_rules_stops_the_run() {
let entries = param_entries();
let fake = Fake::new(&[(
"get",
Canned {
status: 200,
..Default::default()
},
)]);
let src = "PARAM CHOICE(\"au\", \"eu\") TARGET = \"au\"\nREPORT REQUEST get\n";
let bad = run_with_params(src, &entries, &[("TARGET", "us")], &fake);
assert_eq!(bad.errors.len(), 1, "{:?}", bad.errors);
assert!(bad.errors[0].contains("us"), "{:?}", bad.errors);
}
#[test]
fn linear_flow_emits_one_row_and_threads_captures() {
let fake = Fake::new(&[
(
"Oauth",
Canned {
status: 200,
captures: vec![("token".into(), "abc".into())],
..Default::default()
},
),
(
"me",
Canned {
status: 200,
raw_body: "{\"name\":\"jo\"}".into(),
..Default::default()
},
),
]);
let entries = [
entry("Oauth", &[]),
entry("me", &[("name", "jsonpath \"$.name\"")]),
];
let res = run(
"REQUEST Oauth\nREPORT REQUEST me\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(res.rows.len(), 1, "loop-free flow = one row");
assert_eq!(fake.call_vars("me").get("token"), Some(&"abc".to_string()));
assert_eq!(res.rows[0].cells.get("me.name"), Some(&"jo".to_string()));
assert_eq!(res.rows[0].cells.get("me.HttpStatus"), None);
}
#[test]
fn show_selector_prunes_columns_to_listed_fields() {
let fake = Fake::new(&[(
"me",
Canned {
status: 200,
raw_body: "{\"name\":\"jo\",\"blob\":\"AAAAAAAA\"}".into(),
..Default::default()
},
)]);
let entries = [entry("me", &[("name", "jsonpath \"$.name\"")])];
let res = run(
"REPORT REQUEST me SHOW(name, HttpStatus)\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("me.name"), Some(&"jo".to_string()));
assert_eq!(cells.get("me.HttpStatus"), Some(&"200".to_string()));
assert_eq!(cells.get("me.Response"), None);
assert_eq!(cells.get("me.Time"), None);
assert_eq!(cells.get("me.Asserts"), None);
assert_eq!(res.column_order, vec!["me.HttpStatus", "me.name"]);
}
#[test]
fn show_on_bare_request_is_a_noop_for_inclusion() {
let fake = Fake::new(&[(
"me",
Canned {
status: 200,
raw_body: "{}".into(),
..Default::default()
},
)]);
let entries = [entry("me", &[])];
let res = run(
"REPORT REQUEST me SHOW(HttpStatus, bogus)\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(
res.rows[0].cells.get("me.HttpStatus"),
Some(&"200".to_string())
);
assert!(res.rows[0].cells.contains_key("me.Time"));
assert!(res.rows[0].cells.contains_key("me.Asserts"));
assert!(res.rows[0].cells.contains_key("me.Error"));
assert!(res.rows[0].cells.contains_key("me.Response"));
assert_eq!(res.rows[0].cells.get("me.bogus"), None);
}
#[test]
fn assign_overrides_env_and_capture_overrides_assign() {
let fake = Fake::new(&[(
"send",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("send", &[])];
run(
"URL=flow\nREQUEST send\n",
&entries,
&[("URL", "base")],
&[],
&fake,
);
assert_eq!(fake.call_vars("send").get("URL"), Some(&"flow".to_string()));
let fake2 = Fake::new(&[
(
"cap",
Canned {
status: 200,
captures: vec![("URL".into(), "captured".into())],
..Default::default()
},
),
(
"send",
Canned {
status: 200,
..Default::default()
},
),
]);
let entries2 = [entry("cap", &[]), entry("send", &[])];
run(
"URL=flow\nREQUEST cap\nREQUEST send\n",
&entries2,
&[("URL", "base")],
&[],
&fake2,
);
assert_eq!(
fake2.call_vars("send").get("URL"),
Some(&"captured".to_string())
);
}
#[test]
fn report_request_emits_fields_and_suppresses_intrinsics() {
let fake = Fake::new(&[(
"process",
Canned {
status: 201,
raw_body: "{\"status\":\"ok\",\"n\":3}".into(),
pretty_body: "{\n \"status\": \"ok\"\n}".into(),
asserts: vec![(true,), (true,), (false,)],
duration_ms: 42,
..Default::default()
},
)]);
let entries = [entry("process", &[("status", "jsonpath \"$.status\"")])];
let res = run("REPORT REQUEST process\n", &entries, &[], &[], &fake);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("process.status"), Some(&"ok".to_string()));
assert_eq!(cells.get("process.HttpStatus"), None);
assert_eq!(cells.get("process.Time"), None);
assert_eq!(cells.get("process.Asserts"), None);
assert_eq!(cells.get("process.Response"), None);
}
#[test]
fn missing_field_uses_no_match_marker() {
let fake = Fake::new(&[(
"process",
Canned {
status: 200,
raw_body: "{\"a\":1}".into(),
..Default::default()
},
)]);
let entries = [entry("process", &[("missing", "jsonpath \"$.nope\"")])];
let res = run(
"PRELUDE_NO_MATCH_MARKER=\u{2205}\nREPORT REQUEST process\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(res.no_match_marker, "\u{2205}");
let col = crate::report::model::OutputColumn {
header: "m".into(),
sources: vec!["process.missing".into()],
stats: Vec::new(),
image: None,
truth: None,
detail: false,
};
assert_eq!(col.value(&res.rows[0], &res.no_match_marker), "\u{2205}");
}
#[test]
fn report_vars_and_computed_columns() {
let fake = Fake::new(&[]);
let res = run(
"FILE=a.jpg\nREPORT (FILE)\nREPORT FILE AS \"Pretty name\"\nREPORT \"doc-{{FILE}}\" AS label\n",
&[],
&[],
&[],
&fake,
);
assert_eq!(res.rows[0].cells.get("FILE"), Some(&"a.jpg".to_string()));
assert_eq!(
res.rows[0].cells.get("Pretty name"),
Some(&"a.jpg".to_string())
);
assert_eq!(
res.rows[0].cells.get("label"),
Some(&"doc-a.jpg".to_string())
);
}
#[test]
fn list_loop_emits_row_per_element_with_key() {
let fake = Fake::new(&[]);
let res = run(
"LIST DOCS=[\"a\",\"b\",\"c\"]\nFOR X IN DOCS\n REPORT (X)\nEND\n",
&[],
&[],
&[],
&fake,
);
assert_eq!(res.rows.len(), 3);
let names: Vec<_> = res
.rows
.iter()
.filter_map(|r| r.cells.get("X").cloned())
.collect();
assert_eq!(names, vec!["a", "b", "c"]);
assert_eq!(res.rows[0].key, vec!["a".to_string()]);
}
#[test]
fn tuple_list_loop_destructures_and_binds_both() {
let fake = Fake::new(&[(
"up",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("up", &[])];
let res = run(
"LIST DOCS=[(\"f1\",\"b1\"),(\"f2\",\"b2\")]\nFOR (FRONT, BACK) IN DOCS\n REPORT REQUEST up\n REPORT (FRONT, BACK)\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(res.rows.len(), 2);
assert_eq!(res.rows[0].cells.get("FRONT"), Some(&"f1".to_string()));
assert_eq!(res.rows[0].cells.get("BACK"), Some(&"b1".to_string()));
assert_eq!(res.rows[0].key, vec!["f1".to_string(), "b1".to_string()]);
assert_eq!(fake.call_count(), 2);
}
#[test]
fn outer_report_broadcasts_into_every_loop_row() {
let fake = Fake::new(&[
(
"oauth",
Canned {
status: 200,
raw_body: "{}".into(),
..Default::default()
},
),
(
"up",
Canned {
status: 200,
..Default::default()
},
),
]);
let entries = [entry("oauth", &[]), entry("up", &[])];
let res = run(
"REPORT REQUEST oauth\nLIST DOCS=[\"a\",\"b\"]\nFOR X IN DOCS\n REPORT REQUEST up\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(res.rows.len(), 2);
assert_eq!(
fake.calls
.lock()
.unwrap()
.iter()
.filter(|(t, _)| t == "oauth")
.count(),
1
);
for row in &res.rows {
assert!(row.cells.contains_key("oauth.HttpStatus"));
}
}
#[test]
fn outer_report_columns_are_present_on_streamed_rows() {
let fake = Fake::new(&[
(
"oauth",
Canned {
status: 201,
..Default::default()
},
),
(
"up",
Canned {
status: 200,
..Default::default()
},
),
]);
let entries = [entry("oauth", &[]), entry("up", &[])];
let flow = parse_flow(
"REPORT REQUEST oauth\nFOR X IN [\"a\", \"b\"]\n REPORT REQUEST up\nEND\n",
)
.unwrap();
let streamed: Mutex<Vec<ReportRow>> = Mutex::new(Vec::new());
let sink = |ev: RowEvent| {
if let RowEvent::Completed(row) = ev {
streamed.lock().unwrap().push(row.clone());
}
};
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: Some(&sink),
};
let result = run_flow_raw(&flow, &ctx);
let streamed = streamed.into_inner().unwrap();
assert_eq!(result.rows.len(), 2);
assert_eq!(streamed.len(), 2, "one streamed row per loop iteration");
for row in &streamed {
assert_eq!(
row.cells.get("oauth.HttpStatus"),
Some(&"201".to_string()),
"streamed row is missing the broadcast outer-report column"
);
}
}
#[test]
fn envs_loop_sets_target_and_layers_env_vars() {
let fake = Fake::new(&[(
"send",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("send", &[])];
let res = run(
"FOR T IN ENVS \"au\", \"eu\"\n REPORT REQUEST send\nEND\n",
&entries,
&[],
&[("au", &[("REGION", "au-1")]), ("eu", &[("REGION", "eu-1")])],
&fake,
);
assert_eq!(res.rows.len(), 2);
assert_eq!(res.rows[0].target, Some("au".to_string()));
assert_eq!(res.rows[1].target, Some("eu".to_string()));
assert!(res.rows[0].key.is_empty());
assert_eq!(res.rows[0].vars.get("REGION"), Some(&"au-1".to_string()));
}
#[test]
fn streaming_sink_fires_once_per_row_with_unique_ordered_paths() {
let fake = Fake::new(&[(
"send",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("send", &[])];
let flow =
parse_flow("FOR X IN [\"a\", \"b\", \"c\"]\n REPORT REQUEST send\nEND\n").unwrap();
let streamed: Mutex<Vec<ReportRow>> = Mutex::new(Vec::new());
let sink = |ev: RowEvent| {
if let RowEvent::Completed(row) = ev {
streamed.lock().unwrap().push(row.clone());
}
};
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: Some(&sink),
};
let result = run_flow_raw(&flow, &ctx);
let streamed = streamed.into_inner().unwrap();
assert_eq!(result.rows.len(), 3);
assert_eq!(streamed.len(), result.rows.len());
let mut paths: Vec<Vec<(usize, usize)>> = streamed.iter().map(|r| r.path.clone()).collect();
let mut unique = paths.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), paths.len(), "streamed paths are unique");
paths.sort();
let canonical: Vec<Vec<(usize, usize)>> =
result.rows.iter().map(|r| r.path.clone()).collect();
assert_eq!(paths, canonical);
let index: HashMap<Vec<(usize, usize)>, usize> = result
.rows
.iter()
.enumerate()
.map(|(i, r)| (r.path.clone(), i))
.collect();
for row in &streamed {
assert!(
index.contains_key(&row.path),
"every streamed row maps to a skeleton slot"
);
}
}
#[test]
fn streaming_sink_signals_started_before_completed_per_row() {
let fake = Fake::new(&[(
"send",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("send", &[])];
let flow =
parse_flow("FOR X IN [\"a\", \"b\", \"c\"]\n REPORT REQUEST send\nEND\n").unwrap();
#[derive(PartialEq, Debug)]
enum Kind {
Started,
Completed,
}
type EventLog = Vec<(Kind, Vec<(usize, usize)>)>;
let events: Mutex<EventLog> = Mutex::new(Vec::new());
let sink = |ev: RowEvent| {
let mut log = events.lock().unwrap();
match ev {
RowEvent::Started(path) => log.push((Kind::Started, path.to_vec())),
RowEvent::Completed(row) => log.push((Kind::Completed, row.path.clone())),
}
};
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: Some(&sink),
};
let result = run_flow_raw(&flow, &ctx);
let events = events.into_inner().unwrap();
assert_eq!(
events.iter().filter(|(k, _)| *k == Kind::Started).count(),
result.rows.len()
);
assert_eq!(
events.iter().filter(|(k, _)| *k == Kind::Completed).count(),
result.rows.len()
);
for row in &result.rows {
let started = events
.iter()
.position(|(k, p)| *k == Kind::Started && *p == row.path);
let completed = events
.iter()
.position(|(k, p)| *k == Kind::Completed && *p == row.path);
assert!(
matches!((started, completed), (Some(s), Some(c)) if s < c),
"row {:?} must be Started before Completed",
row.path
);
}
}
#[test]
fn with_field_overrides_reports_block() {
let fake = Fake::new(&[(
"p",
Canned {
status: 200,
raw_body: "{\"a\":\"fromwith\",\"b\":\"orig\"}".into(),
..Default::default()
},
)]);
let entries = [entry("p", &[("a", "jsonpath \"$.b\"")])];
let res = run(
"REPORT REQUEST p WITH\n a: jsonpath \"$.a\"\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(res.rows[0].cells.get("p.a"), Some(&"fromwith".to_string()));
}
#[test]
fn response_raw_keeps_original_bytes() {
let fake = Fake::new(&[(
"p",
Canned {
status: 200,
raw_body: "{\"z\":1,\"a\":2}".into(),
pretty_body: "{\n \"z\": 1,\n \"a\": 2\n}".into(),
..Default::default()
},
)]);
let entries = [entry("p", &[])];
let res = run("REPORT REQUEST p RESPONSE RAW\n", &entries, &[], &[], &fake);
assert_eq!(
res.rows[0].cells.get("p.Response"),
Some(&"{\"z\":1,\"a\":2}".to_string())
);
}
#[test]
fn alias_renames_namespace() {
let fake = Fake::new(&[(
"process_file",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("process_file", &[])];
let res = run(
"REPORT REQUEST process_file AS proc\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.rows[0].cells.contains_key("proc.HttpStatus"));
assert!(
!res.rows[0]
.cells
.keys()
.any(|k| k.starts_with("process_file."))
);
}
#[test]
fn spaced_request_name_alias_flows_into_columns() {
let fake = Fake::new(&[(
"My Request",
Canned {
status: 201,
raw_body: "hello".into(),
..Default::default()
},
)]);
let entries = [entry("My Request", &[])];
let src = "# columns: up.HttpStatus AS Status, up.Response AS Body\n\
REPORT REQUEST \"My Request\" AS up\n";
let flow = parse_flow(src).expect("flow parses");
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("up.HttpStatus"), Some(&"201".to_string()));
assert_eq!(cells.get("up.Response"), Some(&"hello".to_string()));
assert!(!cells.keys().any(|k| k.starts_with("My Request.")));
let cols = res.resolved_columns(&flow.header);
let headers: Vec<&str> = cols.iter().map(|c| c.header.as_str()).collect();
assert_eq!(headers, vec!["Status", "Body"]);
assert_eq!(cols[0].value(&res.rows[0], "-"), "201");
assert_eq!(cols[1].value(&res.rows[0], "-"), "hello");
}
#[test]
fn jsonpath_supports_nested_and_index() {
let fake = Fake::new(&[(
"p",
Canned {
status: 200,
raw_body: "{\"items\":[{\"name\":\"first\"},{\"name\":\"second\"}]}".into(),
..Default::default()
},
)]);
let entries = [entry("p", &[("n", "jsonpath \"$.items[1].name\"")])];
let res = run("REPORT REQUEST p\n", &entries, &[], &[], &fake);
assert_eq!(res.rows[0].cells.get("p.n"), Some(&"second".to_string()));
}
#[test]
fn jsonpath_supports_filter_predicates() {
let body = "{\"CardInfo\":[\
{\"key\":\"full_name\",\"value\":\"Jane Citizen\"},\
{\"key\":\"birth_date\",\"value\":\"1990-01-01\"},\
{\"key\":\"tag\",\"value\":\"a\"},\
{\"key\":\"tag\",\"value\":\"b\"}],\
\"nums\":[{\"n\":1,\"v\":\"one\"},{\"n\":2,\"v\":\"two\"}]}";
let fake = Fake::new(&[(
"p",
Canned {
status: 200,
raw_body: body.into(),
..Default::default()
},
)]);
let entries = [entry(
"p",
&[
(
"name",
"jsonpath \"$.CardInfo[?(@.key=='full_name')].value\"",
),
(
"dob",
"jsonpath \"$.CardInfo[?(@.key == \\\"birth_date\\\")].value\"",
),
("tags", "jsonpath \"$.CardInfo[?(@.key=='tag')].value\""),
("two", "jsonpath \"$.nums[?(@.n==2)].v\""),
("not_two", "jsonpath \"$.nums[?(@.n!=2)].v\""),
("gone", "jsonpath \"$.CardInfo[?(@.key=='nope')].value\""),
],
)];
let res = run("REPORT REQUEST p\n", &entries, &[], &[], &fake);
let c = &res.rows[0].cells;
assert_eq!(c.get("p.name"), Some(&"Jane Citizen".to_string()));
assert_eq!(c.get("p.dob"), Some(&"1990-01-01".to_string()));
assert_eq!(c.get("p.tags"), Some(&"[\"a\",\"b\"]".to_string()));
assert_eq!(c.get("p.two"), Some(&"two".to_string()));
assert_eq!(c.get("p.not_two"), Some(&"one".to_string()));
assert_eq!(c.get("p.gone"), Some(&DEFAULT_NO_MATCH.to_string()));
}
#[test]
fn unresolved_request_records_error_but_still_emits_row() {
let fake = Fake::new(&[]);
let res = run("REPORT REQUEST ghost\n", &[], &[], &[], &fake);
assert_eq!(res.rows.len(), 1);
assert!(res.errors.iter().any(|e| e.contains("ghost")));
assert!(res.rows[0].cells.contains_key("ghost.Error"));
}
#[test]
fn resolve_title_prefers_exact_then_unique_leaf() {
let entries = [entry("auth/Oauth", &[]), entry("upload/process_file", &[])];
assert!(resolve_title(&entries, "auth/Oauth").is_some());
assert!(resolve_title(&entries, "process_file").is_some());
assert!(resolve_title(&entries, "missing").is_none());
}
fn tmpdir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!(
"paperboy_run_{tag}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn files_loop_runs_body_per_file() {
let d = tmpdir("files");
std::fs::write(d.join("a.jpg"), "x").unwrap();
std::fs::write(d.join("b.jpg"), "x").unwrap();
std::fs::write(d.join("skip.png"), "x").unwrap();
let fake = Fake::new(&[(
"up",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("up", &[])];
let flow = parse_flow(
"FOR FILE IN FILES \".\" MATCH \"*.jpg\"\n REPORT REQUEST up\n REPORT (FILE)\nEND\n",
)
.unwrap();
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: Some(d.clone()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert_eq!(res.rows.len(), 2, "one row per matched jpg");
assert!(res.rows[0].cells.get("FILE").unwrap().ends_with("a.jpg"));
assert_eq!(fake.call_count(), 2);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn recursive_folders_loop_skips_containers_and_binds_roles() {
let d = tmpdir("folders_rec");
for case in ["batch_a/june/case_1", "batch_b/case_2"] {
let c = d.join(case);
std::fs::create_dir_all(&c).unwrap();
std::fs::write(c.join("scan_front.jpg"), "x").unwrap();
}
std::fs::write(d.join("batch_a/june/case_1/scan_back.jpg"), "x").unwrap();
let fake = Fake::new(&[(
"up",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("up", &[])];
let flow = parse_flow(
"FOR CASE IN FOLDERS \".\" MATCH \"**\" WITH front=\"*_front.jpg\", back=\"*_back.jpg\"?\n REPORT REQUEST up\n REPORT (CASE)\n REPORT \"{{front}}\" AS Front\n REPORT \"{{back}}\" AS Back\nEND\n",
)
.unwrap();
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: Some(d.clone()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert_eq!(res.rows.len(), 2, "one row per case folder: {:?}", res.rows);
assert!(
res.rows[0]
.cells
.get("Front")
.unwrap()
.ends_with("scan_front.jpg")
);
assert!(
res.rows[0]
.cells
.get("Back")
.unwrap()
.ends_with("scan_back.jpg")
);
assert_eq!(res.rows[1].cells.get("Back").unwrap(), "");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn flat_folders_loop_still_fails_on_a_missing_required_role() {
let d = tmpdir("folders_flat");
std::fs::create_dir_all(d.join("case_1")).unwrap();
let fake = Fake::new(&[]);
let entries: [HurlEntry; 0] = [];
let flow = parse_flow(
"FOR CASE IN FOLDERS \".\" WITH front=\"*_front.jpg\"\n REPORT (CASE)\nEND\n",
)
.unwrap();
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: Some(d.clone()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert!(res.rows.is_empty());
assert!(
res.errors.iter().any(|e| e.contains("front")),
"{:?}",
res.errors
);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn nested_loops_produce_cartesian_product() {
let fake = Fake::new(&[]);
let res = run(
"LIST A=[\"a1\",\"a2\"]\nLIST B=[\"b1\",\"b2\",\"b3\"]\nFOR X IN A\n FOR Y IN B\n REPORT (X, Y)\n END\nEND\n",
&[],
&[],
&[],
&fake,
);
assert_eq!(res.rows.len(), 6, "2 x 3 = 6 rows");
assert_eq!(res.rows[0].key, vec!["a1".to_string(), "b1".to_string()]);
assert_eq!(res.rows[5].key, vec!["a2".to_string(), "b3".to_string()]);
}
#[test]
fn arity_mismatch_is_recorded() {
let fake = Fake::new(&[]);
let res = run(
"LIST DOCS=[(\"f1\",\"b1\")]\nFOR (A, B, C) IN DOCS\n REPORT (A)\nEND\n",
&[],
&[],
&[],
&fake,
);
assert!(res.errors.iter().any(|e| e.contains("binds 3")));
}
#[test]
fn envs_roles_merge_baseline_into_candidate_rows() {
let fake = Fake::new(&[(
"send",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("send", &[])];
let res = run(
"FOR TARGET IN ENVS BASELINE(\"prod\"), COMPARISON(\"stg1\", \"stg2\")\n REPORT REQUEST send\n REPORT (TARGET)\nEND\n",
&entries,
&[],
&[("prod", &[]), ("stg1", &[]), ("stg2", &[])],
&fake,
);
let targets: Vec<_> = res.rows.iter().filter_map(|r| r.target.clone()).collect();
assert_eq!(
targets,
vec!["stg1", "stg2"],
"baseline consumed; candidates remain"
);
assert!(
res.rows
.iter()
.all(|r| r.cells.get(crate::report::compare::RESULT_COLUMN)
== Some(&crate::report::compare::MATCH.to_string()))
);
assert_eq!(fake.call_count(), 3);
}
#[test]
fn comparison_diffs_reported_field_across_envs() {
struct EchoEnv;
impl EntryRunner for EchoEnv {
fn run(&self, base: &HurlEntry, vars: &HashMap<String, String>) -> RunOutput {
let v = vars.get("VERDICT").cloned().unwrap_or_default();
let body = format!("{{\"overall\":\"{v}\"}}");
RunOutput {
entries: vec![EntryOutcome {
entry_index: 0,
method: base.method.clone(),
url: base.url.clone(),
status: 200,
status_text: String::new(),
headers: Vec::new(),
body: body.clone(),
raw_body: body,
asserts: Vec::new(),
captures: Vec::new(),
duration_ms: 0,
setup_ms: 0,
wait_ms: 0,
download_ms: 0,
ok: true,
error: None,
}],
error: None,
}
}
}
let entries = [entry("proc", &[])];
let flow = parse_flow(
"FOR TARGET IN ENVS BASELINE(\"prod\"), COMPARISON(\"staging\")\n FOR FILE IN [\"a\", \"b\"]\n REPORT REQUEST proc WITH\n overall: jsonpath \"$.overall\"\n END\n END\nEND\n",
)
.unwrap();
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: [
(
"prod".to_string(),
[("VERDICT".to_string(), "CLEAR".to_string())]
.into_iter()
.collect(),
),
(
"staging".to_string(),
[("VERDICT".to_string(), "REVIEW".to_string())]
.into_iter()
.collect(),
),
]
.into_iter()
.collect(),
root: None,
runner: &EchoEnv,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert_eq!(res.rows.len(), 2);
for r in &res.rows {
assert_eq!(r.target.as_deref(), Some("staging"));
assert_eq!(r.cells.get("proc.overall"), Some(&"REVIEW".to_string()));
let result_cell = r
.cells
.get(crate::report::compare::RESULT_COLUMN)
.expect("Result column");
let parsed: serde_json::Value = serde_json::from_str(result_cell).expect("valid JSON");
let obj = parsed.as_object().expect("object");
assert!(obj.contains_key("prod (baseline)"));
assert!(obj.contains_key("staging"));
assert_eq!(obj["prod (baseline)"]["overall"], "CLEAR");
assert_eq!(obj["staging"]["overall"], "REVIEW");
}
assert_eq!(
res.column_order.first(),
Some(&crate::report::compare::RESULT_COLUMN.to_string())
);
}
#[test]
fn baseline_directive_diffs_run_against_saved_snapshot() {
use crate::report::baseline::Baseline;
use crate::report::compare::RESULT_COLUMN;
struct Echo;
impl EntryRunner for Echo {
fn run(&self, base: &HurlEntry, vars: &HashMap<String, String>) -> RunOutput {
let v = vars.get("VERDICT").cloned().unwrap_or_default();
let body = format!("{{\"overall\":\"{v}\"}}");
RunOutput {
entries: vec![EntryOutcome {
entry_index: 0,
method: base.method.clone(),
url: base.url.clone(),
status: 200,
status_text: String::new(),
headers: Vec::new(),
body: body.clone(),
raw_body: body,
asserts: Vec::new(),
captures: Vec::new(),
duration_ms: 0,
setup_ms: 0,
wait_ms: 0,
download_ms: 0,
ok: true,
error: None,
}],
error: None,
}
}
}
let dir = tmpdir("baseline");
let entries = [entry("proc", &[])];
let flow_src = "FOR FILE IN [\"a\", \"b\"]\n REPORT REQUEST proc WITH\n overall: jsonpath \"$.overall\"\n END\nEND\n";
let flow = parse_flow(flow_src).unwrap();
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: [("VERDICT".to_string(), "CLEAR".to_string())]
.into_iter()
.collect(),
named_envs: HashMap::new(),
root: Some(dir.clone()),
runner: &Echo,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let first = run_flow(&flow, &ctx);
let snap_path = dir.join("proc.baseline");
Baseline::from_result(&first).save(&snap_path).unwrap();
let flow2 = parse_flow(&format!("# baseline: proc.baseline\n{flow_src}")).unwrap();
let ctx2 = RunContext {
entries: &entries,
helpers: &[],
base_vars: [("VERDICT".to_string(), "REVIEW".to_string())]
.into_iter()
.collect(),
named_envs: HashMap::new(),
root: Some(dir.clone()),
runner: &Echo,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let second = run_flow(&flow2, &ctx2);
assert_eq!(
second.column_order.first(),
Some(&RESULT_COLUMN.to_string()),
"Result column surfaced"
);
assert_eq!(second.rows.len(), 2);
for r in &second.rows {
let result_cell = r.cells.get(RESULT_COLUMN).expect("Result column");
let parsed: serde_json::Value = serde_json::from_str(result_cell).expect("valid JSON");
let obj = parsed.as_object().expect("object");
assert!(obj.contains_key("baseline (baseline)"));
assert!(obj.contains_key("comparison"));
assert_eq!(
obj["baseline (baseline)"]["overall"], "CLEAR",
"every row differs from its snapshot sibling"
);
assert_eq!(
obj["comparison"]["overall"], "REVIEW",
"every row differs from its snapshot sibling"
);
}
assert!(second.errors.is_empty(), "no baseline load error");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn envs_baseline_file_diffs_live_comparison_against_a_snapshot() {
use crate::report::baseline::Baseline;
use crate::report::compare::RESULT_COLUMN;
struct Echo;
impl EntryRunner for Echo {
fn run(&self, base: &HurlEntry, vars: &HashMap<String, String>) -> RunOutput {
let v = vars.get("VERDICT").cloned().unwrap_or_default();
let body = format!("{{\"overall\":\"{v}\"}}");
RunOutput {
entries: vec![EntryOutcome {
entry_index: 0,
method: base.method.clone(),
url: base.url.clone(),
status: 200,
status_text: String::new(),
headers: Vec::new(),
body: body.clone(),
raw_body: body,
asserts: Vec::new(),
captures: Vec::new(),
duration_ms: 0,
setup_ms: 0,
wait_ms: 0,
download_ms: 0,
ok: true,
error: None,
}],
error: None,
}
}
}
let dir = tmpdir("envs_baseline_file");
let entries = [entry("proc", &[])];
let body_src = "FOR FILE IN [\"a\", \"b\"]\n REPORT REQUEST proc WITH\n overall: jsonpath \"$.overall\"\n END\nEND\n";
let base_flow = parse_flow(body_src).unwrap();
let base_ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: [("VERDICT".to_string(), "CLEAR".to_string())]
.into_iter()
.collect(),
named_envs: HashMap::new(),
root: Some(dir.clone()),
runner: &Echo,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let first = run_flow(&base_flow, &base_ctx);
let snap_path = dir.join("prod.baseline");
Baseline::from_result(&first).save(&snap_path).unwrap();
let cmp_src = format!(
"FOR TARGET IN ENVS BASELINE(FILE(\"prod.baseline\")), COMPARISON(\"staging\")\n{body_src}END\n"
);
let cmp_flow = parse_flow(&cmp_src).unwrap();
let cmp_ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: [(
"staging".to_string(),
[("VERDICT".to_string(), "REVIEW".to_string())]
.into_iter()
.collect(),
)]
.into_iter()
.collect(),
root: Some(dir.clone()),
runner: &Echo,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let cmp = run_flow(&cmp_flow, &cmp_ctx);
assert!(
cmp.errors.is_empty(),
"snapshot loaded cleanly: {:?}",
cmp.errors
);
assert_eq!(
cmp.column_order.first(),
Some(&RESULT_COLUMN.to_string()),
"Result column surfaced"
);
assert_eq!(cmp.rows.len(), 2);
for r in &cmp.rows {
let cell = r.cells.get(RESULT_COLUMN).expect("Result column");
let parsed: serde_json::Value = serde_json::from_str(cell).expect("valid JSON");
let obj = parsed.as_object().expect("object");
assert!(obj.contains_key("prod.baseline (baseline)"));
assert!(obj.contains_key("staging"));
assert_eq!(obj["prod.baseline (baseline)"]["overall"], "CLEAR");
assert_eq!(obj["staging"]["overall"], "REVIEW");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn envs_baseline_file_reports_a_missing_snapshot() {
let fake = Fake::new(&[(
"proc",
Canned {
status: 200,
raw_body: "{\"overall\":\"REVIEW\"}".into(),
..Default::default()
},
)]);
let entries = [entry("proc", &[])];
let res = run(
"FOR TARGET IN ENVS BASELINE(FILE(\"nope.baseline\")), COMPARISON(\"staging\")\n REPORT REQUEST proc WITH\n overall: jsonpath \"$.overall\"\n END\nEND\n",
&entries,
&[],
&[("staging", &[])],
&fake,
);
assert!(
res.errors.iter().any(|e| e.contains("nope.baseline")),
"missing snapshot surfaced as an error: {:?}",
res.errors
);
}
#[test]
fn baseline_directive_matches_when_unchanged() {
use crate::report::baseline::Baseline;
use crate::report::compare::{MATCH, RESULT_COLUMN};
let dir = tmpdir("baseline_match");
let fake = Fake::new(&[(
"proc",
Canned {
status: 200,
raw_body: "{\"overall\":\"CLEAR\"}".into(),
..Default::default()
},
)]);
let entries = [entry("proc", &[])];
let flow_src = "FOR FILE IN [\"a\"]\n REPORT REQUEST proc WITH\n overall: jsonpath \"$.overall\"\n END\nEND\n";
let flow = parse_flow(flow_src).unwrap();
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: Some(dir.clone()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let first = run_flow(&flow, &ctx);
let snap_path = dir.join("proc.baseline");
Baseline::from_result(&first).save(&snap_path).unwrap();
let flow2 = parse_flow(&format!("# baseline: proc.baseline\n{flow_src}")).unwrap();
let second = run_flow(&flow2, &ctx);
assert_eq!(second.rows.len(), 1);
assert_eq!(
second.rows[0].cells.get(RESULT_COLUMN),
Some(&MATCH.to_string())
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn baseline_directive_missing_file_is_a_run_error() {
let fake = Fake::new(&[(
"proc",
Canned {
status: 200,
raw_body: "{}".into(),
..Default::default()
},
)]);
let entries = [entry("proc", &[])];
let flow = parse_flow("# baseline: nope.baseline\nREPORT REQUEST proc\n").unwrap();
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: Some(std::env::temp_dir()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert_eq!(res.rows.len(), 1, "rows still produced");
assert!(
res.errors.iter().any(|e| e.starts_with("baseline ")),
"missing snapshot recorded as a run error: {:?}",
res.errors
);
}
#[test]
fn parallel_loop_matches_sequential_output() {
let canned = [(
"up",
Canned {
status: 200,
raw_body: "{}".into(),
..Default::default()
},
)];
let entries = [entry("up", &[])];
let body = "FOR X IN [\"a\",\"b\",\"c\",\"d\",\"e\"]\n REPORT REQUEST up\n REPORT (X)\nEND\n";
let seq = Fake::new(&canned);
let seq_res = run(body, &entries, &[], &[], &seq);
let par = Fake::new(&canned);
let par_res = run(&format!("PARALLEL {body}"), &entries, &[], &[], &par);
let seq_x: Vec<_> = seq_res
.rows
.iter()
.map(|r| r.cells.get("X").cloned())
.collect();
let par_x: Vec<_> = par_res
.rows
.iter()
.map(|r| r.cells.get("X").cloned())
.collect();
assert_eq!(seq_x, par_x, "parallel output order matches sequential");
assert_eq!(
par_x,
vec![
Some("a".into()),
Some("b".into()),
Some("c".into()),
Some("d".into()),
Some("e".into())
]
);
assert_eq!(par_res.column_order, seq_res.column_order);
}
#[test]
fn parallel_loop_actually_runs_concurrently() {
let canned = [(
"up",
Canned {
status: 200,
..Default::default()
},
)];
let entries = [entry("up", &[])];
let body = "FOR X IN [\"a\",\"b\",\"c\",\"d\"]\n REPORT REQUEST up\nEND\n";
let par = Fake::new(&canned).with_delay(40);
run(&format!("PARALLEL(4) {body}"), &entries, &[], &[], &par);
assert!(par.peak_concurrency() >= 2, "parallel loop overlaps calls");
let seq = Fake::new(&canned).with_delay(40);
run(body, &entries, &[], &[], &seq);
assert_eq!(seq.peak_concurrency(), 1, "sequential loop never overlaps");
}
#[test]
fn parallel_degree_caps_concurrency() {
let canned = [(
"up",
Canned {
status: 200,
..Default::default()
},
)];
let entries = [entry("up", &[])];
let fake = Fake::new(&canned).with_delay(20);
let res = run(
"PARALLEL(2) FOR X IN [\"a\",\"b\",\"c\",\"d\",\"e\",\"f\"]\n REPORT REQUEST up\n REPORT (X)\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(res.rows.len(), 6);
assert!(
fake.peak_concurrency() <= 2,
"degree caps concurrency at 2, saw {}",
fake.peak_concurrency()
);
}
#[test]
fn loop_captures_do_not_leak_to_continuation() {
let fake = Fake::new(&[
(
"inside",
Canned {
status: 200,
captures: vec![("secret".into(), "leaked".into())],
..Default::default()
},
),
(
"after",
Canned {
status: 200,
..Default::default()
},
),
]);
let entries = [entry("inside", &[]), entry("after", &[])];
run(
"FOR X IN [\"a\"]\n REQUEST inside\nEND\nREQUEST after\n",
&entries,
&[],
&[],
&fake,
);
assert!(
!fake.call_vars("after").contains_key("secret"),
"loop captures must not leak past END"
);
}
#[test]
fn parallel_envs_loop_preserves_role_order() {
let fake = Fake::new(&[(
"send",
Canned {
status: 200,
..Default::default()
},
)])
.with_delay(20);
let entries = [entry("send", &[])];
let res = run(
"PARALLEL FOR TARGET IN ENVS BASELINE(\"prod\"), COMPARISON(\"stg1\", \"stg2\")\n REPORT REQUEST send\n REPORT (TARGET)\nEND\n",
&entries,
&[],
&[("prod", &[]), ("stg1", &[]), ("stg2", &[])],
&fake,
);
let targets: Vec<_> = res.rows.iter().filter_map(|r| r.target.clone()).collect();
assert_eq!(targets, vec!["stg1", "stg2"]);
assert!(
fake.peak_concurrency() >= 2,
"ENVS loop runs envs concurrently"
);
}
#[test]
fn with_fields_suppress_intrinsics_by_default() {
let fake = Fake::new(&[(
"svc",
Canned {
status: 200,
raw_body: "{\"score\":42}".into(),
..Default::default()
},
)]);
let entries = [entry("svc", &[("score", "jsonpath \"$.score\"")])];
let res = run(
"REPORT REQUEST svc WITH\n extra: jsonpath \"$.score\"\nEND\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("svc.extra"), Some(&"42".to_string()));
assert_eq!(cells.get("svc.score"), Some(&"42".to_string()));
assert_eq!(
cells.get("svc.HttpStatus"),
None,
"intrinsics suppressed by WITH"
);
assert_eq!(cells.get("svc.Time"), None);
assert_eq!(cells.get("svc.Response"), None);
}
#[test]
fn reports_only_request_suppresses_intrinsics() {
let fake = Fake::new(&[(
"svc",
Canned {
status: 200,
raw_body: "{\"score\":42}".into(),
..Default::default()
},
)]);
let entries = [entry("svc", &[("score", "jsonpath \"$.score\"")])];
let res = run("REPORT REQUEST svc\n", &entries, &[], &[], &fake);
let cells = &res.rows[0].cells;
assert_eq!(
cells.get("svc.HttpStatus"),
None,
"intrinsics suppressed by [Reports] fields"
);
assert_eq!(cells.get("svc.score"), Some(&"42".to_string()));
}
#[test]
fn show_readds_intrinsic_on_reports_only_request() {
let fake = Fake::new(&[(
"svc",
Canned {
status: 200,
raw_body: "{\"score\":42}".into(),
..Default::default()
},
)]);
let entries = [entry("svc", &[("score", "jsonpath \"$.score\"")])];
let res = run(
"REPORT REQUEST svc SHOW(HttpStatus)\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("svc.HttpStatus"), Some(&"200".to_string()));
assert_eq!(cells.get("svc.score"), Some(&"42".to_string()));
assert_eq!(cells.get("svc.Time"), None);
assert_eq!(cells.get("svc.Response"), None);
assert_eq!(res.column_order, vec!["svc.HttpStatus", "svc.score"]);
}
#[test]
fn show_readds_intrinsic_alongside_with_field() {
let fake = Fake::new(&[(
"svc",
Canned {
status: 200,
raw_body: "{\"score\":42}".into(),
..Default::default()
},
)]);
let entries = [entry("svc", &[])];
let res = run(
"REPORT REQUEST svc SHOW(HttpStatus, extra) WITH\n extra: jsonpath \"$.score\"\nEND\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(
cells.get("svc.HttpStatus"),
Some(&"200".to_string()),
"SHOW re-added intrinsic"
);
assert_eq!(cells.get("svc.extra"), Some(&"42".to_string()));
assert_eq!(cells.get("svc.Response"), None);
assert_eq!(res.column_order, vec!["svc.HttpStatus", "svc.extra"]);
}
#[test]
fn with_field_query_can_alias_an_intrinsic() {
let fake = Fake::new(&[(
"svc",
Canned {
status: 201,
duration_ms: 137,
..Default::default()
},
)]);
let entries = [entry("svc", &[])];
let res = run(
"REPORT REQUEST svc WITH\n Status: HttpStatus\n \"Response Time\": Time\nEND\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("svc.Status"), Some(&"201".to_string()));
assert_eq!(cells.get("svc.Response Time"), Some(&"137".to_string()));
assert_eq!(cells.get("svc.HttpStatus"), None);
assert_eq!(cells.get("svc.Time"), None);
}
#[test]
fn with_field_statistics_flow_into_summary_rows() {
let fake = Fake::new(&[(
"svc",
Canned {
status: 200,
duration_ms: 100,
..Default::default()
},
)]);
let entries = [entry("svc", &[])];
let res = run(
"FOR X IN [1, 2, 3]\n REPORT REQUEST svc WITH\n Elapsed: Time STATISTICS(MEAN)\n END\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(
res.column_stats.get("svc.Elapsed"),
Some(&vec![StatKind::Mean])
);
let cols = res.resolved_columns(&crate::report::flow::Header::default());
let summary = res.summary_rows(&cols);
assert_eq!(summary.len(), 1);
let idx = cols
.iter()
.position(|c| c.header == "svc.Elapsed")
.expect("Elapsed column present");
assert_eq!(summary[0].text_cell(idx), "100");
}
#[test]
fn hide_removes_named_field_from_reports_only_request() {
let fake = Fake::new(&[(
"svc",
Canned {
status: 200,
raw_body: "{\"score\":42}".into(),
..Default::default()
},
)]);
let entries = [entry("svc", &[("score", "jsonpath \"$.score\"")])];
let res = run(
"REPORT REQUEST svc HIDE(score)\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("svc.score"), None, "score hidden by HIDE");
assert_eq!(cells.get("svc.HttpStatus"), None);
}
#[test]
fn hide_removes_named_field_from_bare_request() {
let fake = Fake::new(&[(
"svc",
Canned {
status: 200,
raw_body: "{\"score\":42}".into(),
..Default::default()
},
)]);
let entries = [entry("svc", &[])];
let res = run(
"REPORT REQUEST svc HIDE(Response, Error)\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("svc.Response"), None, "Response hidden");
assert_eq!(cells.get("svc.Error"), None, "Error hidden");
assert_eq!(cells.get("svc.HttpStatus"), Some(&"200".to_string()));
assert!(cells.contains_key("svc.Time"));
assert!(cells.contains_key("svc.Asserts"));
}
#[test]
fn hide_applied_after_show_on_bare_request() {
let fake = Fake::new(&[(
"svc",
Canned {
status: 200,
raw_body: "{}".into(),
..Default::default()
},
)]);
let entries = [entry("svc", &[])];
let res = run(
"REPORT REQUEST svc SHOW(HttpStatus, Time) HIDE(Time)\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("svc.HttpStatus"), Some(&"200".to_string()));
assert_eq!(cells.get("svc.Time"), None, "HIDE removed Time");
assert!(cells.contains_key("svc.Asserts"));
assert!(cells.contains_key("svc.Error"));
assert!(cells.contains_key("svc.Response"));
}
#[test]
fn worked_ex1_bare_request_emits_all_intrinsics() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
..Default::default()
},
)]);
let entries = [entry("r", &[])];
let res = run("REPORT REQUEST r\n", &entries, &[], &[], &fake);
let cells = &res.rows[0].cells;
assert!(cells.contains_key("r.HttpStatus"));
assert!(cells.contains_key("r.Time"));
assert!(cells.contains_key("r.Asserts"));
assert!(cells.contains_key("r.Error"));
assert!(cells.contains_key("r.Response"));
assert_eq!(
res.column_order,
vec![
"r.HttpStatus",
"r.Time",
"r.Asserts",
"r.Error",
"r.Response"
]
);
}
#[test]
fn worked_ex2_reports_field_suppresses_intrinsics() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"status\":\"ok\"}".into(),
..Default::default()
},
)]);
let entries = [entry("r", &[("Status", "jsonpath \"$.status\"")])];
let res = run("REPORT REQUEST r\n", &entries, &[], &[], &fake);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("r.Status"), Some(&"ok".to_string()));
assert_eq!(cells.get("r.HttpStatus"), None, "intrinsics suppressed");
assert_eq!(cells.get("r.Time"), None);
assert_eq!(cells.get("r.Response"), None);
assert_eq!(res.column_order, vec!["r.Status"]);
}
#[test]
fn worked_ex3_reports_field_show_adds_intrinsic() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"status\":\"ok\"}".into(),
..Default::default()
},
)]);
let entries = [entry("r", &[("Status", "jsonpath \"$.status\"")])];
let res = run(
"REPORT REQUEST r SHOW(HttpStatus)\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("r.HttpStatus"), Some(&"200".to_string()));
assert_eq!(cells.get("r.Status"), Some(&"ok".to_string()));
assert_eq!(res.column_order, vec!["r.HttpStatus", "r.Status"]);
}
#[test]
fn worked_ex4a_with_only_emits_with_field() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"x\":7}".into(),
..Default::default()
},
)]);
let entries = [entry("r", &[])];
let res = run(
"REPORT REQUEST r WITH\n Foo: jsonpath \"$.x\"\nEND\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("r.Foo"), Some(&"7".to_string()));
assert_eq!(cells.get("r.HttpStatus"), None, "intrinsics suppressed");
assert_eq!(res.column_order, vec!["r.Foo"]);
}
#[test]
fn worked_ex4b_with_show_adds_intrinsic() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"x\":7}".into(),
duration_ms: 55,
..Default::default()
},
)]);
let entries = [entry("r", &[])];
let res = run(
"REPORT REQUEST r SHOW(Time) WITH\n Foo: jsonpath \"$.x\"\nEND\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("r.Time"), Some(&"55".to_string()));
assert_eq!(cells.get("r.Foo"), Some(&"7".to_string()));
assert_eq!(res.column_order, vec!["r.Time", "r.Foo"]);
}
#[test]
fn time_breakdown_is_opt_in_on_a_bare_request() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
duration_ms: 90,
setup_ms: 60,
wait_ms: 25,
download_ms: 5,
..Default::default()
},
)]);
let entries = [entry("r", &[])];
let res = run("REPORT REQUEST r\n", &entries, &[], &[], &fake);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("r.Time"), Some(&"90".to_string()));
assert_eq!(cells.get("r.TimeSetup"), None);
assert_eq!(cells.get("r.TimeWait"), None);
assert_eq!(cells.get("r.TimeDownload"), None);
}
#[test]
fn show_selects_individual_time_breakdown_columns() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
duration_ms: 90,
setup_ms: 60,
wait_ms: 25,
download_ms: 5,
..Default::default()
},
)]);
let entries = [entry("r", &[])];
let res = run(
"REPORT REQUEST r SHOW(Time, TimeWait)\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("r.TimeWait"), Some(&"25".to_string()));
assert_eq!(cells.get("r.Time"), Some(&"90".to_string()));
assert_eq!(cells.get("r.HttpStatus"), Some(&"200".to_string()));
assert_eq!(cells.get("r.TimeSetup"), None);
assert_eq!(cells.get("r.TimeDownload"), None);
}
#[test]
fn time_breakdown_is_available_to_with_fields_and_declared_show() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"x\":7}".into(),
duration_ms: 90,
setup_ms: 60,
wait_ms: 25,
download_ms: 5,
..Default::default()
},
)]);
let entries = [entry("r", &[])];
let res = run(
"REPORT REQUEST r SHOW(TimeSetup) WITH\n Server: TimeWait\n Body: TimeDownload\nEND\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("r.TimeSetup"), Some(&"60".to_string()));
assert_eq!(cells.get("r.Server"), Some(&"25".to_string()));
assert_eq!(cells.get("r.Body"), Some(&"5".to_string()));
assert_eq!(cells.get("r.Time"), None, "intrinsics suppressed");
assert_eq!(res.column_order, vec!["r.TimeSetup", "r.Server", "r.Body"]);
}
#[test]
fn worked_ex5_reports_with_show_additive_union() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"a\":1,\"b\":2}".into(),
..Default::default()
},
)]);
let entries = [entry("r", &[("A", "jsonpath \"$.a\"")])];
let res = run(
"REPORT REQUEST r SHOW(Response) WITH\n B: jsonpath \"$.b\"\nEND\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(
cells.get("r.Response"),
Some(&"{\"a\":1,\"b\":2}".to_string())
);
assert_eq!(cells.get("r.A"), Some(&"1".to_string()));
assert_eq!(cells.get("r.B"), Some(&"2".to_string()));
assert_eq!(res.column_order, vec!["r.Response", "r.A", "r.B"]);
}
#[test]
fn an_image_column_resolves_local_files_during_the_run() {
let dir = tmpdir("images");
let png = crate::report::image::tests::png_1x1();
std::fs::write(dir.join("a.png"), &png).unwrap();
std::fs::write(dir.join("b.png"), &png).unwrap();
std::fs::write(dir.join("c.png"), b"not an image").unwrap();
let flow = parse_flow(
"FOR SHOT IN FILES \".\" MATCH \"*.png\"\n REPORT SHOT AS Frame IMAGE(HEIGHT 60)\nEND\n",
)
.expect("flow parses");
let fake = Fake::new(&[]);
let ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: Some(dir.clone()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert_eq!(res.rows.len(), 3);
let row_of = |name: &str| {
res.rows
.iter()
.position(|r| r.cells.get("Frame").is_some_and(|v| v.ends_with(name)))
.unwrap_or_else(|| panic!("row for {name}"))
};
for name in ["a.png", "b.png"] {
let img = res
.images
.get(&(row_of(name), "Frame".to_string()))
.unwrap_or_else(|| panic!("resolved {name}"));
assert_eq!(img.mime, "image/png");
assert_eq!(img.bytes, png);
}
assert!(
!res.images
.contains_key(&(row_of("c.png"), "Frame".to_string())),
"a non-picture value resolves to nothing"
);
assert!(
!res.rows[row_of("c.png")].cells["Frame"].is_empty(),
"and its cell keeps its text"
);
assert!(
res.errors.iter().any(|e| e.contains("c.png")),
"with a note saying why: {:?}",
res.errors
);
let cols = res.resolved_columns(&flow.header);
assert_eq!(
cols[0].image.and_then(|i| i.height),
Some(60),
"the IMAGE clause reaches the output column"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_truth_column_scores_each_row_through_the_label_classes() {
let dir = tmpdir("truth");
std::fs::write(
dir.join("labels.csv"),
"answer,expected\nLow Risk,real\nLow Risk,fake\nHigh Risk,\n",
)
.unwrap();
let flow = parse_flow(
"# labels: Pass = pass, real, low risk\n # labels: Fail = fail, fake, high risk\n FOR ROW IN TUPLES FROM \"labels.csv\"\n \x20 REPORT \"{{ answer }}\" AS Verdict TRUTH \"{{ expected }}\"\n END\n",
)
.expect("flow parses");
let fake = Fake::new(&[]);
let ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: Some(dir.clone()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert_eq!(res.rows.len(), 3);
let verdict = |r: usize| res.verdicts.get(&(r, "Verdict".to_string())).copied();
assert_eq!(
verdict(0),
Some(Verdict::Correct),
"`Low Risk` and `real` are the same class"
);
assert_eq!(verdict(1), Some(Verdict::Incorrect));
assert_eq!(
verdict(2),
Some(Verdict::Untested),
"a blank ground truth is never scored as a pass"
);
assert_eq!(
res.truths
.get(&(0, "Verdict".to_string()))
.map(String::as_str),
Some("real"),
"the resolved truth is kept beside the verdict"
);
assert!(
!res.truths.contains_key(&(2, "Verdict".to_string())),
"an untested row has no truth to record"
);
assert_eq!(
res.rows[0].cells.get("Verdict").map(String::as_str),
Some("Low Risk"),
"scoring never rewrites the reported value"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_comparison_trends_each_row_towards_or_away_from_its_truth() {
struct EchoEnv;
impl EntryRunner for EchoEnv {
fn run(&self, base: &HurlEntry, vars: &HashMap<String, String>) -> RunOutput {
let v = vars.get("VERDICT").cloned().unwrap_or_default();
let body = format!("{{\"overall\":\"{v}\"}}");
RunOutput {
entries: vec![EntryOutcome {
entry_index: 0,
method: base.method.clone(),
url: base.url.clone(),
status: 200,
status_text: String::new(),
headers: Vec::new(),
body: body.clone(),
raw_body: body,
asserts: Vec::new(),
captures: Vec::new(),
duration_ms: 0,
setup_ms: 0,
wait_ms: 0,
download_ms: 0,
ok: true,
error: None,
}],
error: None,
}
}
}
let dir = tmpdir("trend");
std::fs::write(dir.join("truth.csv"), "name,expected\na,REVIEW\nb,CLEAR\n").unwrap();
let entries = [entry("proc", &[])];
let flow = parse_flow(
"FOR TARGET IN ENVS BASELINE(\"prod\"), COMPARISON(\"staging\")\n FOR ROW IN TUPLES FROM \"truth.csv\"\n REPORT REQUEST proc WITH\n overall: jsonpath \"$.overall\" TRUTH \"{{ expected }}\"\n END\n END\nEND\n",
)
.expect("flow parses");
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: [
(
"prod".to_string(),
[("VERDICT".to_string(), "CLEAR".to_string())]
.into_iter()
.collect(),
),
(
"staging".to_string(),
[("VERDICT".to_string(), "REVIEW".to_string())]
.into_iter()
.collect(),
),
]
.into_iter()
.collect(),
root: Some(dir.clone()),
runner: &EchoEnv,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(res.rows.len(), 2, "one candidate row per manifest line");
let trend = |r: usize| res.rows[r].cells.get(TREND_COLUMN).map(String::as_str);
assert_eq!(
trend(0),
Some(Trend::Fixed.as_str()),
"prod answered CLEAR where the truth is REVIEW; staging got it right"
);
assert_eq!(
trend(1),
Some(Trend::Regressed.as_str()),
"and the other way round on the second row"
);
assert_eq!(
res.trends.get(&(0, "proc.overall".to_string())).copied(),
Some(Trend::Fixed),
"the per-cell trend is recorded too, for tinting the cell itself"
);
for r in &res.rows {
assert!(
r.cells
.get(crate::report::compare::RESULT_COLUMN)
.is_some_and(|v| v.contains("overall")),
"both rows report the same structural change"
);
}
let at = |c: &str| res.column_order.iter().position(|x| x == c);
assert_eq!(
at(TREND_COLUMN)
.zip(at(CORRECT_COLUMN))
.map(|(t, c)| t == c + 1),
Some(true),
"Trend sits right after Correct: {:?}",
res.column_order
);
}
#[test]
fn a_snapshot_comparison_trends_against_what_the_snapshot_answered() {
let dir = tmpdir("trend_snap");
std::fs::write(dir.join("truth.csv"), "name,expected\na,yes\nb,no\n").unwrap();
let body = "FOR ROW IN TUPLES FROM \"truth.csv\"\n REPORT \"{{ ANSWER }}\" AS Verdict TRUTH \"{{ expected }}\"\nEND\n";
let fake = Fake::new(&[]);
let mut ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: [("ANSWER".to_string(), "yes".to_string())]
.into_iter()
.collect(),
named_envs: HashMap::new(),
root: Some(dir.clone()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let first = run_flow(&parse_flow(body).expect("flow parses"), &ctx);
let snap = dir.join("prev.baseline");
super::super::baseline::Baseline::from_result(&first)
.save(&snap)
.unwrap();
ctx.base_vars = [("ANSWER".to_string(), "no".to_string())]
.into_iter()
.collect();
let src = format!("# baseline: prev.baseline\n{body}");
let res = run_flow(&parse_flow(&src).expect("flow parses"), &ctx);
std::fs::remove_dir_all(&dir).ok();
assert!(res.errors.is_empty(), "snapshot loaded: {:?}", res.errors);
let trend = |r: usize| res.rows[r].cells.get(TREND_COLUMN).map(String::as_str);
assert_eq!(trend(0), Some(Trend::Regressed.as_str()));
assert_eq!(trend(1), Some(Trend::Fixed.as_str()));
}
#[test]
fn a_truth_without_a_comparison_produces_no_trend_column() {
let flow = parse_flow("REPORT \"yes\" AS A TRUTH \"yes\"\n").expect("flow parses");
let fake = Fake::new(&[]);
let ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert!(
!res.column_order.iter().any(|c| c == TREND_COLUMN),
"no comparison, no Trend: {:?}",
res.column_order
);
assert!(res.trends.is_empty());
}
#[test]
fn the_correct_column_rolls_up_a_row_and_favours_the_bad_news() {
let flow =
parse_flow("REPORT \"yes\" AS A TRUTH \"yes\"\nREPORT \"yes\" AS B TRUTH \"no\"\n")
.expect("flow parses");
let fake = Fake::new(&[]);
let ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert_eq!(
res.rows[0].cells.get(CORRECT_COLUMN).map(String::as_str),
Some("incorrect"),
"one wrong column makes the row wrong"
);
assert_eq!(
res.column_order.first().map(String::as_str),
Some(CORRECT_COLUMN),
"and the column leads the table"
);
}
#[test]
fn a_single_binder_over_a_named_manifest_row_is_not_an_arity_mismatch() {
let dir = tmpdir("tuplearity");
std::fs::write(dir.join("m.csv"), "answer,expected\nyes,yes\n").unwrap();
let flow =
parse_flow("FOR ROW IN TUPLES FROM \"m.csv\"\n REPORT \"{{ answer }}\" AS A\nEND\n")
.expect("flow parses");
let fake = Fake::new(&[]);
let ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: Some(dir.clone()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(res.rows[0].cells.get("A").map(String::as_str), Some("yes"));
let flow = parse_flow("FOR (a, b, c) IN TUPLES FROM \"m.csv\"\n REPORT a\nEND\n")
.expect("flow parses");
let res = run_flow(&flow, &ctx);
assert!(
res.errors.iter().any(|e| e.contains("binds 3")),
"{:?}",
res.errors
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_truth_referencing_an_unknown_variable_is_untested() {
let flow =
parse_flow("REPORT \"yes\" AS Verdict TRUTH \"{{ nowhere }}\"\n").expect("flow parses");
let fake = Fake::new(&[]);
let ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert_eq!(
res.verdicts.get(&(0, "Verdict".to_string())).copied(),
Some(Verdict::Untested)
);
}
#[test]
fn a_truth_without_declared_labels_compares_literally() {
let flow = parse_flow(
"APPROVED = approved\nREPORT \" Approved \" AS Verdict TRUTH \"{{ APPROVED }}\"\n",
)
.expect("flow parses");
let fake = Fake::new(&[]);
let ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert_eq!(
res.verdicts.get(&(0, "Verdict".to_string())).copied(),
Some(Verdict::Correct)
);
}
#[test]
fn a_report_without_a_truth_clause_records_no_verdicts() {
let flow = parse_flow("REPORT \"yes\" AS Verdict\n").expect("flow parses");
let fake = Fake::new(&[]);
let ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert!(res.verdicts.is_empty() && res.truths.is_empty());
}
#[test]
fn a_column_without_the_clause_resolves_no_images() {
let dir = tmpdir("noimages");
std::fs::write(dir.join("a.png"), crate::report::image::tests::png_1x1()).unwrap();
let flow =
parse_flow("FOR SHOT IN FILES \".\" MATCH \"*.png\"\n REPORT SHOT AS Frame\nEND\n")
.expect("flow parses");
let fake = Fake::new(&[]);
let ctx = RunContext {
entries: &[],
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: Some(dir.clone()),
runner: &fake,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let res = run_flow(&flow, &ctx);
assert!(res.images.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn baseline_show_surfaces_an_opt_in_intrinsic_on_a_bare_request() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"a\":1}".into(),
duration_ms: 7,
..Default::default()
},
)]);
let entries = [entry("r", &[])];
let src = "FOR T IN ENVS BASELINE(\"prod\") SHOW(TimeSetup), COMPARISON(\"staging\")\n REPORT REQUEST r AS proc\nEND\n";
let res = run(
src,
&entries,
&[],
&[("prod", &[][..]), ("staging", &[][..])],
&fake,
);
let cells = &res.rows[0].cells;
assert!(
cells.contains_key("proc.TimeSetup"),
"the SHOWn field has to survive the opt-in filter: {:?}",
cells.keys().collect::<Vec<_>>()
);
assert!(
cells.contains_key("baseline.proc.TimeSetup"),
"and the baseline copy has something to copy from: {:?}",
cells.keys().collect::<Vec<_>>()
);
assert!(!cells.contains_key("proc.TimeWait"));
}
#[test]
fn baseline_show_surfaces_the_intrinsic_on_a_request_with_declared_fields() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"a\":1}".into(),
duration_ms: 7,
..Default::default()
},
)]);
let entries = [entry("r", &[("A", "jsonpath \"$.a\"")])];
let src = "FOR T IN ENVS BASELINE(\"prod\") SHOW(Time), COMPARISON(\"staging\")\n REPORT REQUEST r AS proc\nEND\n";
let res = run(
src,
&entries,
&[],
&[("prod", &[][..]), ("staging", &[][..])],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("proc.Time"), Some(&"7".to_string()));
assert_eq!(cells.get("baseline.proc.Time"), Some(&"7".to_string()));
assert_eq!(cells.get("proc.HttpStatus"), None);
assert_eq!(cells.get("proc.Response"), None);
let flow = parse_flow(src).unwrap();
let cols = res.resolved_columns(&flow.header);
let headers: Vec<&str> = cols.iter().map(|c| c.header.as_str()).collect();
assert!(
headers.contains(&"baseline.proc.Time"),
"columns were {headers:?}"
);
}
#[test]
fn worked_ex6_hide_removes_any_field() {
let fake = Fake::new(&[(
"r",
Canned {
status: 200,
raw_body: "{\"a\":1,\"b\":2}".into(),
..Default::default()
},
)]);
let entries = [entry("r", &[("A", "jsonpath \"$.a\"")])];
let res = run(
"REPORT REQUEST r SHOW(Response) HIDE(A) WITH\n B: jsonpath \"$.b\"\nEND\n",
&entries,
&[],
&[],
&fake,
);
let cells = &res.rows[0].cells;
assert_eq!(cells.get("r.A"), None, "A removed by HIDE");
assert!(cells.contains_key("r.Response"));
assert_eq!(cells.get("r.B"), Some(&"2".to_string()));
assert_eq!(res.column_order, vec!["r.Response", "r.B"]);
}
}
#[cfg(test)]
mod helper_collection_tests {
use super::*;
use crate::report::flow::split_collection_ref;
fn e(title: &str) -> HurlEntry {
HurlEntry {
title: title.to_string(),
method: "GET".into(),
url: "http://x".into(),
..Default::default()
}
}
#[test]
fn an_alias_is_split_off_the_reference() {
assert_eq!(
split_collection_ref("./helpers.hurl AS helpers"),
("./helpers.hurl", Some("helpers"))
);
assert_eq!(
split_collection_ref("git:origin/qa/shared.hurl as shared"),
("git:origin/qa/shared.hurl", Some("shared"))
);
}
#[test]
fn a_path_containing_as_is_left_alone() {
assert_eq!(
split_collection_ref("./as-built/api.hurl"),
("./as-built/api.hurl", None)
);
assert_eq!(
split_collection_ref("./my report.hurl"),
("./my report.hurl", None)
);
}
#[test]
fn collections_lists_the_primary_first_then_helpers() {
let flow = crate::report::parser::parse_flow(
"# collection: ./api.hurl\n# collection: ./helpers.hurl AS h\n\nREQUEST a\n",
)
.expect("parses");
let cols = flow.header.collections();
assert_eq!(cols.len(), 2);
assert_eq!(
cols[0],
crate::report::flow::CollectionRef {
reference: "./api.hurl",
alias: None
}
);
assert_eq!(cols[1].alias, Some("h"));
assert_eq!(flow.header.collection(), Some("./api.hurl"));
}
#[test]
fn a_qualified_name_resolves_within_its_helper() {
let primary = [e("upload")];
let helpers = [HelperCollection {
alias: "h".into(),
entries: vec![e("fetch_frame")],
}];
assert_eq!(
resolve_qualified(&primary, &helpers, "h/fetch_frame").map(|e| &e.title),
Some(&"fetch_frame".to_string())
);
assert!(resolve_qualified(&primary, &helpers, "fetch_frame").is_none());
assert!(resolve_qualified(&primary, &helpers, "upload").is_some());
}
#[test]
fn a_flow_runs_a_request_from_a_helper_collection() {
use std::sync::Mutex;
struct Recorder(Mutex<Vec<String>>);
impl EntryRunner for Recorder {
fn run(&self, base: &HurlEntry, _vars: &HashMap<String, String>) -> RunOutput {
self.0.lock().unwrap().push(base.title.clone());
RunOutput {
entries: vec![EntryOutcome {
entry_index: 0,
method: base.method.clone(),
url: base.url.clone(),
status: 200,
status_text: String::new(),
headers: Vec::new(),
body: String::new(),
raw_body: String::new(),
asserts: Vec::new(),
captures: Vec::new(),
duration_ms: 0,
setup_ms: 0,
wait_ms: 0,
download_ms: 0,
ok: true,
error: None,
}],
error: None,
}
}
}
let flow = crate::report::parser::parse_flow(
"# collection: ./api.hurl\n# collection: ./h.hurl AS h\n\nREPORT REQUEST h/fetch_frame\n",
)
.expect("parses");
let primary = [e("upload")];
let helpers = [HelperCollection {
alias: "h".into(),
entries: vec![e("fetch_frame")],
}];
let runner = Recorder(Mutex::new(Vec::new()));
let ctx = RunContext {
entries: &primary,
helpers: &helpers,
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &runner,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
let result = run_flow(&flow, &ctx);
assert_eq!(
*runner.0.lock().unwrap(),
vec!["fetch_frame".to_string()],
"the helper's own entry was sent"
);
assert_eq!(result.rows.len(), 1);
}
#[test]
fn an_undeclared_prefix_is_still_a_folder_path() {
let primary = [e("auth/login")];
let helpers = [HelperCollection {
alias: "h".into(),
entries: vec![e("login")],
}];
assert!(resolve_qualified(&primary, &helpers, "auth/login").is_some());
}
}
#[cfg(test)]
mod timing_column_tests {
use super::*;
use std::sync::Mutex;
struct Timed(Mutex<Vec<u64>>);
impl EntryRunner for Timed {
fn run(&self, base: &HurlEntry, _vars: &HashMap<String, String>) -> RunOutput {
let mut n = self.0.lock().unwrap();
let ms = 100 + n.len() as u64;
n.push(ms);
RunOutput {
entries: vec![EntryOutcome {
entry_index: 0,
method: base.method.clone(),
url: base.url.clone(),
status: 200,
status_text: String::new(),
headers: Vec::new(),
body: "{\"verdict\":\"CLEAR\"}".into(),
raw_body: "{\"verdict\":\"CLEAR\"}".into(),
asserts: Vec::new(),
captures: Vec::new(),
duration_ms: ms,
setup_ms: 1,
wait_ms: 2,
download_ms: 3,
ok: true,
error: None,
}],
error: None,
}
}
}
fn run(src: &str) -> ReportResult {
let flow = crate::report::parser::parse_flow(src).expect("parses");
let entries = [HurlEntry {
title: "face".into(),
method: "GET".into(),
url: "http://x".into(),
..Default::default()
}];
let runner = Timed(Mutex::new(Vec::new()));
let ctx = RunContext {
entries: &entries,
helpers: &[],
base_vars: HashMap::new(),
named_envs: HashMap::new(),
root: None,
runner: &runner,
strings: crate::i18n::Strings::english(),
params: Default::default(),
sink: None,
};
run_flow(&flow, &ctx)
}
#[test]
fn a_with_field_aliasing_a_time_is_recorded_as_a_timing_column() {
let result = run(concat!(
"# collection: ./api.hurl\n\n",
"REPORT REQUEST face AS f WITH\n",
" \"Response Time\": Time STATISTICS(MEAN, MEDIAN)\n",
" Setup: TimeSetup\n",
" Verdict: jsonpath \"$.verdict\"\n",
"END\n",
));
let mut cols: Vec<&str> = result.timing_columns.iter().map(String::as_str).collect();
cols.sort();
assert_eq!(
cols,
vec!["f.Response Time", "f.Setup"],
"both aliased times are recorded, and nothing else is"
);
assert_eq!(result.rows[0].cells["f.Response Time"], "100");
assert_eq!(result.rows[0].cells["f.Setup"], "1");
}
#[test]
fn a_renamed_status_is_not_a_timing_column() {
let result = run(concat!(
"# collection: ./api.hurl\n\n",
"REPORT REQUEST face AS f WITH\n",
" Status: HttpStatus\n",
" Body: Response\n",
"END\n",
));
assert!(
result.timing_columns.is_empty(),
"{:?}",
result.timing_columns
);
}
}