use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Condvar, Mutex};
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, RowRole, 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 {
generated: Default::default(),
entries: vec![EntryOutcome {
entry_index: 0,
superseded: false,
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>>,
pub shuffle: Option<u64>,
}
#[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 note_role(out: &mut HashMap<String, Vec<String>>, written: String, got: String) {
let seen = out.entry(written).or_default();
if !seen.contains(&got) {
seen.push(got);
}
}
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), &HashMap::new())
.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,
role_targets: ex.role_targets,
column_order: ex.column_order,
no_match_marker,
errors: ex.errors,
skipped: ex.skipped,
warnings: ex.warnings,
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),
&result.role_targets,
) {
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>,
step_captures: HashMap<String, crate::report::produced::Produced>,
capture_owner: HashMap<String, String>,
key_parts: Vec<String>,
path: Vec<(usize, usize)>,
target: Option<String>,
role: RowRole,
comparison: Option<String>,
target_env: Option<HashMap<String, String>>,
broadcast: HashMap<String, String>,
column_order: Vec<String>,
timing_columns: Vec<String>,
errors: Vec<String>,
skipped: Vec<String>,
warnings: Vec<String>,
role_targets: HashMap<String, Vec<String>>,
step_order: Vec<String>,
step_request: HashMap<String, String>,
step_ok: HashMap<String, bool>,
baseline_show: Vec<String>,
}
#[derive(Clone)]
struct ExecState {
scopes: Vec<HashMap<String, String>>,
lists: HashMap<String, Producer>,
captures: HashMap<String, String>,
step_captures: HashMap<String, crate::report::produced::Produced>,
capture_owner: HashMap<String, String>,
step_ok: HashMap<String, bool>,
step_order: Vec<String>,
step_request: HashMap<String, String>,
key_parts: Vec<String>,
path: Vec<(usize, usize)>,
target: Option<String>,
role: RowRole,
comparison: Option<String>,
target_env: Option<HashMap<String, String>>,
broadcast: HashMap<String, String>,
baseline_show: Vec<String>,
}
struct IterOut {
rows: Vec<ReportRow>,
role_targets: HashMap<String, Vec<String>>,
columns: Vec<String>,
timing_columns: Vec<String>,
errors: Vec<String>,
skipped: Vec<String>,
warnings: Vec<String>,
}
struct StepOut {
produced: crate::report::produced::Produced,
ok: bool,
was_skipped: bool,
request: String,
cells: HashMap<String, String>,
columns: Vec<String>,
timing_columns: Vec<String>,
errors: Vec<String>,
warnings: Vec<String>,
}
struct Sched {
waiting: Vec<usize>,
ready: Vec<usize>,
ok: Vec<Option<bool>>,
out: Vec<Option<StepOut>>,
running: usize,
rng: u64,
rng_on: bool,
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum DepGate {
Succeeded,
Produced(String),
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
struct CleanupDep {
step: String,
gate: DepGate,
}
type PlannedCleanup<'a> = (usize, Option<usize>, &'a FlowNode, String, Vec<String>);
fn order_cleanups(
planned: Vec<PlannedCleanup<'_>>,
) -> (Vec<PlannedCleanup<'_>>, Vec<String>, Vec<String>) {
let index: HashMap<&str, usize> = planned
.iter()
.enumerate()
.map(|(i, (_, _, _, step, _))| (step.as_str(), i))
.collect();
let n = planned.len();
let mut waiting = vec![0usize; n];
let mut succ: Vec<Vec<usize>> = vec![Vec::new(); n];
for (i, (_, _, _, _, deps)) in planned.iter().enumerate() {
for d in deps {
if let Some(&j) = index.get(d.as_str())
&& j != i
{
succ[j].push(i);
waiting[i] += 1;
}
}
}
let mut done = vec![false; n];
let mut out_idx: Vec<usize> = Vec::with_capacity(n);
let mut cyclic: Vec<String> = Vec::new();
let mut stalled: Vec<String> = Vec::new();
while out_idx.len() < n {
let Some(next) = (0..n).find(|&i| !done[i] && waiting[i] == 0) else {
let stuck: Vec<usize> = (0..n).filter(|&i| !done[i]).collect();
let reaches_itself = |from: usize| {
let mut seen = vec![false; n];
let mut stack = succ[from].clone();
while let Some(k) = stack.pop() {
if k == from {
return true;
}
if !done[k] && !seen[k] {
seen[k] = true;
stack.extend(succ[k].iter().copied());
}
}
false
};
cyclic.extend(
stuck
.iter()
.filter(|&&i| reaches_itself(i))
.map(|&i| planned[i].3.clone()),
);
stalled.extend(stuck.iter().map(|&i| planned[i].3.clone()));
out_idx.extend((0..n).filter(|&i| !done[i]));
break;
};
done[next] = true;
out_idx.push(next);
for &k in &succ[next] {
waiting[k] -= 1;
}
}
let mut slots: Vec<Option<PlannedCleanup<'_>>> = planned.into_iter().map(Some).collect();
let ordered = out_idx
.into_iter()
.filter_map(|i| slots[i].take())
.collect();
(ordered, cyclic, stalled)
}
struct InFlight<'s> {
sched: &'s Mutex<Sched>,
idle: &'s Condvar,
armed: bool,
}
impl Drop for InFlight<'_> {
fn drop(&mut self) {
if !self.armed {
return;
}
if let Ok(mut s) = self.sched.lock() {
s.running -= 1;
}
self.idle.notify_all();
}
}
fn schedule_graph<'a>(
ctx: &'a RunContext<'a>,
plan: &super::graph::Plan,
body: &[FlowNode],
base: &ExecState,
degree: usize,
seed: Option<u64>,
) -> Vec<Option<StepOut>> {
let n = plan.steps.len();
let mut seen = std::collections::HashSet::new();
let mut succ: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut waiting = vec![0usize; n];
for e in &plan.edges {
if seen.insert((e.from, e.to)) {
succ[e.from].push(e.to);
waiting[e.to] += 1;
}
}
let ready: Vec<usize> = (0..n).filter(|&i| waiting[i] == 0).collect();
let sched = Mutex::new(Sched {
waiting,
ready,
ok: vec![None; n],
out: (0..n).map(|_| None).collect(),
running: 0,
rng: seed.unwrap_or(0) | 1,
rng_on: seed.is_some(),
});
let idle = Condvar::new();
let work = || {
loop {
let claimed = {
let mut s = sched.lock().unwrap();
loop {
if let Some(pos) = pick(&mut s) {
let idx = s.ready.remove(pos);
if let Some(dep) = blocker(&s, plan, idx) {
s.out[idx] = Some(skip_out(ctx, plan, body, idx, &dep));
if finish(&mut s, &succ, idx, false) {
idle.notify_all();
}
continue;
}
let state = view_for(plan, base, &s, idx);
s.running += 1;
break Some((idx, state));
}
if s.running == 0 {
break None;
}
s = idle.wait(s).unwrap();
}
};
let Some((idx, state)) = claimed else {
idle.notify_all();
break;
};
let mut flight = InFlight {
sched: &sched,
idle: &idle,
armed: true,
};
let out = run_step(ctx, plan, body, idx, state);
let mut s = sched.lock().unwrap();
let ok = out.ok;
s.out[idx] = Some(out);
s.running -= 1;
flight.armed = false;
finish(&mut s, &succ, idx, ok);
drop(s);
idle.notify_all();
}
};
if degree <= 1 {
work();
} else {
std::thread::scope(|sc| {
for _ in 0..degree {
sc.spawn(work);
}
});
}
let sched = sched.into_inner().unwrap();
sched.out
}
fn pick(s: &mut Sched) -> Option<usize> {
if s.ready.is_empty() {
return None;
}
if !s.rng_on {
return Some(0);
}
let mut x = s.rng;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
s.rng = x;
Some((x % s.ready.len() as u64) as usize)
}
fn blocker(s: &Sched, plan: &super::graph::Plan, idx: usize) -> Option<String> {
plan.incoming(idx)
.into_iter()
.find(|e| !s.ok[e.from].unwrap_or(false))
.map(|e| plan.steps[e.from].name.clone())
}
fn finish(s: &mut Sched, succ: &[Vec<usize>], idx: usize, ok: bool) -> bool {
s.ok[idx] = Some(ok);
let mut released = false;
for &to in &succ[idx] {
s.waiting[to] -= 1;
if s.waiting[to] == 0 {
let at = s.ready.partition_point(|&i| i < to);
s.ready.insert(at, to);
released = true;
}
}
released
}
fn view_for(plan: &super::graph::Plan, base: &ExecState, s: &Sched, idx: usize) -> ExecState {
let mut st = base.clone();
for anc in plan.ancestors(idx) {
let Some(out) = &s.out[anc] else { continue };
st.step_captures
.insert(plan.steps[anc].name.clone(), out.produced.clone());
for (k, v) in out.produced.iter() {
st.captures.insert(k.clone(), v.clone());
st.capture_owner
.insert(k.clone(), plan.steps[anc].name.clone());
}
}
st
}
fn skip_out(
ctx: &RunContext<'_>,
plan: &super::graph::Plan,
body: &[FlowNode],
idx: usize,
dep: &str,
) -> StepOut {
let step = &plan.steps[idx];
let mut out = StepOut {
produced: crate::report::produced::Produced::default(),
ok: false,
was_skipped: true,
request: step.request.clone(),
cells: HashMap::new(),
columns: Vec::new(),
timing_columns: Vec::new(),
errors: Vec::new(),
warnings: Vec::new(),
};
if let FlowNode::Report(ReportStmt::Request { .. }) = &body[step.written] {
let key = format!("{}.Error", step.name);
out.cells.insert(
key.clone(),
crate::i18n::fill(ctx.strings.run_step_skipped, &[dep]),
);
out.columns.push(key);
}
out
}
fn run_step(
ctx: &RunContext<'_>,
plan: &super::graph::Plan,
body: &[FlowNode],
idx: usize,
state: ExecState,
) -> StepOut {
let step = &plan.steps[idx];
let mut ex = Exec::from_state(ctx, state);
let mut cells = HashMap::new();
match &body[step.written] {
FlowNode::Request {
name, alias, using, ..
} => {
ex.run_request(name, alias.as_deref(), using);
}
FlowNode::Report(stmt) => {
for (k, v) in ex.eval_report(stmt) {
ex.note_column(&k);
cells.insert(k, v);
}
}
_ => {}
}
StepOut {
produced: ex
.step_captures
.get(&step.name)
.cloned()
.unwrap_or_default(),
ok: ex.step_ok.get(&step.name).copied().unwrap_or(true),
was_skipped: false,
request: step.request.clone(),
cells,
columns: ex.column_order,
timing_columns: ex.timing_columns,
errors: ex.errors,
warnings: ex.warnings,
}
}
impl<'a> Exec<'a> {
fn new(ctx: &'a RunContext<'a>) -> Self {
Exec {
ctx,
scopes: vec![HashMap::new()],
lists: HashMap::new(),
captures: HashMap::new(),
step_captures: HashMap::new(),
capture_owner: HashMap::new(),
step_ok: HashMap::new(),
step_order: Vec::new(),
step_request: HashMap::new(),
key_parts: Vec::new(),
path: Vec::new(),
target: None,
role: RowRole::Unknown,
comparison: None,
target_env: None,
broadcast: HashMap::new(),
column_order: Vec::new(),
timing_columns: Vec::new(),
errors: Vec::new(),
skipped: Vec::new(),
warnings: Vec::new(),
role_targets: HashMap::new(),
baseline_show: Vec::new(),
}
}
fn to_state(&self) -> ExecState {
ExecState {
scopes: self.scopes.clone(),
lists: self.lists.clone(),
captures: self.captures.clone(),
step_captures: self.step_captures.clone(),
capture_owner: self.capture_owner.clone(),
step_ok: self.step_ok.clone(),
step_order: self.step_order.clone(),
step_request: self.step_request.clone(),
key_parts: self.key_parts.clone(),
path: self.path.clone(),
target: self.target.clone(),
role: self.role,
comparison: self.comparison.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,
step_captures: state.step_captures,
capture_owner: state.capture_owner,
step_ok: state.step_ok,
step_order: state.step_order,
step_request: state.step_request,
key_parts: state.key_parts,
path: state.path,
target: state.target,
role: state.role,
comparison: state.comparison,
target_env: state.target_env,
broadcast: state.broadcast,
column_order: Vec::new(),
timing_columns: Vec::new(),
errors: Vec::new(),
skipped: Vec::new(),
warnings: Vec::new(),
role_targets: HashMap::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 vars_for_source(&self) -> HashMap<String, String> {
let mut m = self.vars_for();
m.retain(|k, _| !k.contains('.'));
for (step, caps) in &self.step_captures {
for (k, v) in caps.iter() {
m.insert(format!("{step}.{k}"), v.clone());
}
}
m
}
fn record_captures(&mut self, step: &str, captures: &[(String, String)]) {
self.record_values(
step,
captures,
crate::report::produced::Provenance::Captured,
);
}
fn record_values(
&mut self,
step: &str,
captures: &[(String, String)],
how: crate::report::produced::Provenance,
) {
let own = self.step_captures.entry(step.to_string()).or_default();
for (k, v) in captures {
match how {
crate::report::produced::Provenance::Captured => own.record_captured(k, v),
crate::report::produced::Provenance::Generated => own.record_generated(k, v),
}
}
for (k, v) in captures {
self.captures.insert(k.clone(), v.clone());
self.capture_owner.insert(k.clone(), step.to_string());
}
}
fn record_generated(&mut self, step: &str, generated: &HashMap<String, String>) {
if generated.is_empty() {
return;
}
let mut pairs: Vec<(String, String)> = generated
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
pairs.sort();
self.record_values(step, &pairs, crate::report::produced::Provenance::Generated);
}
fn note_step(&mut self, step: &str, request: &str, ok: bool) {
if self.step_ok.insert(step.to_string(), ok).is_none() {
self.step_order.push(step.to_string());
}
self.step_request
.insert(step.to_string(), request.to_string());
}
fn lookup(&self, key: &str) -> Option<String> {
if let Some((step, var)) = key.split_once('.')
&& let Some(v) = self.step_captures.get(step).and_then(|c| c.get(var))
{
return Some(v.clone());
}
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;
let mut cleanups: Vec<&FlowNode> = Vec::new();
for (node_index, node) in nodes.iter().enumerate() {
match node {
FlowNode::Comment(_) => {}
FlowNode::Assign { key, value } => {
let v = substitute(&unquote(value), &self.vars_for_source());
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_source());
self.set_var(&p.name, v);
}
Err(e) => self.errors.push(e),
}
}
FlowNode::Request {
name, alias, using, ..
} => {
self.run_request(name, alias.as_deref(), 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,
));
}
FlowNode::Graph { body, parallel, .. } => {
self.run_graph(body, parallel.as_ref(), &mut own)
}
FlowNode::Cleanup { .. } => cleanups.push(node),
}
}
self.run_cleanups(&cleanups);
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 run_cleanups(&mut self, cleanups: &[&FlowNode]) {
if cleanups.is_empty() {
return;
}
let siblings: Vec<(String, String)> = cleanups
.iter()
.filter_map(|node| match node {
FlowNode::Cleanup { name, alias, .. } => Some((
alias.clone().unwrap_or_else(|| leaf(name).to_string()),
name.clone(),
)),
_ => None,
})
.collect();
let mut planned: Vec<(usize, Option<usize>, &FlowNode, String, Vec<String>)> = Vec::new();
for (written, node) in cleanups.iter().enumerate() {
let FlowNode::Cleanup {
name,
alias,
depends,
using,
} = node
else {
continue;
};
let step = alias.clone().unwrap_or_else(|| leaf(name).to_string());
let deps: Vec<String> = self
.cleanup_deps(name, depends, using, &step, &siblings)
.into_iter()
.map(|d| d.step)
.collect();
let depth = deps
.iter()
.filter_map(|d| self.step_order.iter().position(|s| s == d))
.max();
planned.push((written, depth, node, step, deps));
}
planned.sort_by_key(|(written, depth, ..)| {
(std::cmp::Reverse(*depth), std::cmp::Reverse(*written))
});
let cyclic;
let stalled;
(planned, cyclic, stalled) = order_cleanups(planned);
if !cyclic.is_empty() {
self.errors.push(crate::i18n::fill(
self.ctx.strings.run_cleanup_cycle,
&[&cyclic.join(", ")],
));
}
for (_, _, node, step, deps) in &planned {
if !stalled.contains(step) {
continue;
}
let FlowNode::Cleanup { name, .. } = node else {
continue;
};
self.skipped.push(step.clone());
self.note_step(step, name, false);
if cyclic.contains(step) {
continue;
}
if let Some(dep) = deps.iter().find(|d| stalled.contains(d)) {
self.warnings.push(crate::i18n::fill(
self.ctx.strings.run_cleanup_skipped,
&[step, dep],
));
}
}
for (_, _, node, step, _) in planned {
let FlowNode::Cleanup {
name,
alias,
depends,
using,
} = node
else {
continue;
};
if stalled.contains(&step) {
continue;
}
let deps = self.cleanup_deps(name, depends, using, &step, &[]);
if let Some(dep) = deps.into_iter().find(|d| match &d.gate {
DepGate::Succeeded => !self.step_ok.get(&d.step).copied().unwrap_or(false),
DepGate::Produced(var) => !self
.step_captures
.get(&d.step)
.is_some_and(|c| c.contains(var)),
}) {
self.skipped.push(step.clone());
self.note_step(&step, name, false);
self.warnings.push(crate::i18n::fill(
self.ctx.strings.run_cleanup_skipped,
&[&step, &dep.step],
));
continue;
}
let before = self.errors.len();
self.run_request(name, alias.as_deref(), using);
let failed = !self.step_ok.get(&step).copied().unwrap_or(false);
let raised: Vec<String> = self.errors.drain(before..).collect();
if failed {
let detail = raised.into_iter().next().unwrap_or_else(|| step.clone());
self.warnings.push(crate::i18n::fill(
self.ctx.strings.run_cleanup_failed,
&[&step, &detail],
));
}
}
}
fn cleanup_deps(
&self,
name: &str,
depends: &[String],
using: &[UsingItem],
self_step: &str,
siblings: &[(String, String)],
) -> Vec<CleanupDep> {
let mut out: Vec<CleanupDep> = depends
.iter()
.map(|d| CleanupDep {
step: d.clone(),
gate: DepGate::Succeeded,
})
.collect();
let gate_for = |step: &str, request: &str, var: &str| -> Option<DepGate> {
let e = resolve_qualified(self.ctx.entries, self.ctx.helpers, request)?;
if self
.step_captures
.get(step)
.is_some_and(|p| p.is_generated(var))
{
return Some(DepGate::Produced(var.to_string()));
}
if e.captures.iter().any(|(c, _)| c == var) {
return Some(DepGate::Succeeded);
}
if e.generators.iter().any(|(g, _)| g == var) {
return Some(DepGate::Produced(var.to_string()));
}
None
};
let request_of = |step: &str| -> Option<String> {
self.step_request.get(step).cloned().or_else(|| {
siblings
.iter()
.find(|(s, _)| s == step)
.map(|(_, r)| r.clone())
})
};
let mut refs: Vec<String> = Vec::new();
if let Some(entry) = resolve_qualified(self.ctx.entries, self.ctx.helpers, name) {
refs.extend(crate::request::entry_referenced_keys(entry));
}
for item in using {
if let UsingItem::Override { value, .. } = item {
refs.extend(crate::environment::referenced_keys(value));
}
}
for r in refs {
match r.split_once('.') {
Some((step, var))
if self.step_ok.contains_key(step)
|| siblings.iter().any(|(s, _)| s == step && s != self_step) =>
{
let gate = request_of(step)
.and_then(|req| gate_for(step, &req, var))
.unwrap_or(DepGate::Succeeded);
out.push(CleanupDep {
step: step.to_string(),
gate,
})
}
Some(_) => {}
None => {
let sibs: Vec<CleanupDep> = siblings
.iter()
.filter(|(s, _)| s != self_step)
.filter_map(|(s, req)| {
gate_for(s, req, &r).map(|gate| CleanupDep {
step: s.clone(),
gate,
})
})
.collect();
if !sibs.is_empty() {
out.extend(sibs);
continue;
}
if let Some(owner) = self.capture_owner.get(&r) {
let gate = request_of(owner)
.and_then(|req| gate_for(owner, &req, &r))
.unwrap_or(DepGate::Succeeded);
out.push(CleanupDep {
step: owner.clone(),
gate,
});
continue;
}
out.extend(self.step_order.iter().filter_map(|step| {
let req = self.step_request.get(step)?;
gate_for(step, req, &r).map(|gate| CleanupDep {
step: step.clone(),
gate,
})
}));
}
}
}
out.sort();
out.dedup();
out
}
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(),
role: self.role,
comparison: self.comparison.clone(),
};
if let Some(sink) = self.ctx.sink {
sink(RowEvent::Completed(&row));
}
row
}
fn run_request(
&mut self,
name: &str,
alias: Option<&str>,
using: &[UsingItem],
) -> Option<EntryOutcome> {
let step = alias.unwrap_or_else(|| leaf(name)).to_string();
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"));
self.note_step(&step, name, false);
return None;
}
};
let base = match self.apply_using(name, base, using, &self.vars_for_source()) {
Ok(base) => base,
Err(e) => {
self.errors.push(e);
self.note_step(&step, name, false);
return None;
}
};
let out = self.ctx.runner.run(&base, &self.vars_for());
self.record_generated(&step, &out.generated);
if let Some(err) = &out.error {
self.errors.push(format!("{name}: {err}"));
}
let eo = out.entries.into_iter().next();
match &eo {
Some(eo) => {
self.record_captures(&step, &eo.captures);
self.note_step(&step, name, eo.ok);
}
None => self.note_step(&step, name, false),
}
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_source());
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}'"),
));
self.note_step(&alias, name, false);
return cells;
}
};
let base = match self.apply_using(name, base, using, &self.vars_for_source()) {
Ok(base) => base,
Err(e) => {
self.errors.push(e.clone());
cells.push((format!("{alias}.Error"), e));
self.note_step(&alias, name, false);
return cells;
}
};
let out = self.ctx.runner.run(&base, &self.vars_for());
self.record_generated(&alias, &out.generated);
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));
self.note_step(&alias, name, false);
return cells;
}
};
self.record_captures(&alias, &eo.captures);
self.note_step(&alias, name, eo.ok);
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,
role_targets: sub.role_targets,
columns: sub.column_order,
timing_columns: sub.timing_columns,
errors: sub.errors,
skipped: sub.skipped,
warnings: sub.warnings,
}
};
self.run_iterations(items.len(), parallel, run_one)
}
fn run_graph(
&mut self,
body: &[FlowNode],
parallel: Option<&ParallelSpec>,
own: &mut HashMap<String, String>,
) {
let plan =
match super::graph::build(body, self.ctx.entries, self.ctx.helpers, self.ctx.strings) {
Ok(p) => p,
Err(errs) => {
self.errors.extend(errs);
return;
}
};
let base = self.to_state();
let degree = match parallel {
Some(spec) => self.parallel_degree(spec, plan.steps.len()),
None => 1,
};
let outs = schedule_graph(self.ctx, &plan, body, &base, degree, self.ctx.shuffle);
for &idx in &plan.order {
let step = &plan.steps[idx];
let Some(out) = &outs[idx] else { continue };
self.note_step(&step.name, &out.request, out.ok);
if out.was_skipped {
self.skipped.push(step.name.clone());
}
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.iter().cloned());
self.warnings.extend(out.warnings.iter().cloned());
for (k, v) in &out.cells {
own.insert(k.clone(), v.clone());
}
}
for &idx in &plan.order {
let Some(out) = &outs[idx] else { continue };
self.step_captures
.insert(plan.steps[idx].name.clone(), out.produced.clone());
for (k, v) in out.produced.iter() {
self.captures.insert(k.clone(), v.clone());
self.capture_owner
.insert(k.clone(), plan.steps[idx].name.clone());
}
}
}
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 mut live_roles: Vec<RowRole> = Vec::new();
let mut file_roles: Vec<RowRole> = Vec::new();
let vars = self.vars_for_source();
let resolve = |s: &String| crate::environment::substitute(s, &vars);
let mut resolved_roles: Vec<(String, String)> = Vec::new();
match clause {
EnvClause::Plain(names) => {
live = names.iter().map(resolve).collect();
live_roles = vec![RowRole::Unassigned; live.len()];
}
EnvClause::Roles {
baseline,
comparisons,
..
} => {
for (r, role) in baseline
.iter()
.map(|r| (r, RowRole::Baseline))
.chain(comparisons.iter().map(|r| (r, RowRole::Candidate)))
{
let target = r.target().to_string();
let got = resolve(&target);
resolved_roles.push((target, got.clone()));
match r {
RoleRef::Env(_) => {
live.push(got);
live_roles.push(role);
}
RoleRef::File(_) => {
files.push(got);
file_roles.push(role);
}
}
}
}
}
for (written, got) in resolved_roles {
note_role(&mut self.role_targets, written, got);
}
let mut seed = self.to_state();
for (k, v) in inherited {
seed.broadcast.insert(k.clone(), v.clone());
}
let ctx = self.ctx;
let keyed = matches!(clause, EnvClause::Plain(_));
let inherit = keyed && matches!(self.role, RowRole::Baseline | RowRole::Candidate);
let comparison = (!keyed).then(|| {
let mut id: Vec<String> = self.path.iter().map(|(n, _)| n.to_string()).collect();
id.push(node_index.to_string());
id.join("/")
});
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_env = ctx.named_envs.get(name).cloned();
if !inherit {
sub.target = Some(name.clone());
sub.role = live_roles[i];
sub.comparison = comparison.clone();
}
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());
if keyed {
sub.key_parts.push(name.clone());
}
let rows = sub.exec_block(body);
IterOut {
rows,
role_targets: sub.role_targets,
columns: sub.column_order,
timing_columns: sub.timing_columns,
errors: sub.errors,
skipped: sub.skipped,
warnings: sub.warnings,
}
};
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());
row.role = file_roles[fi];
row.comparison = comparison.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);
self.skipped.extend(out.skipped);
self.warnings.extend(out.warnings);
for (written, got) in out.role_targets {
for one in got {
note_role(&mut self.role_targets, written.clone(), one);
}
}
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_source())
}
}
pub(super) 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(())
}
pub(super) 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("\\\\", "\\"))
}
pub(crate) 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)>,
generated: 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,
panic_on: Option<String>,
}
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,
panic_on: None,
}
}
fn with_delay(mut self, ms: u64) -> Self {
self.delay_ms = ms;
self
}
fn panicking_on(mut self, title: &str) -> Self {
self.panic_on = Some(title.to_string());
self
}
fn call_order(&self) -> Vec<String> {
self.calls
.lock()
.unwrap()
.iter()
.map(|(t, _)| t.clone())
.collect()
}
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.panic_on.as_deref() == Some(base.title.as_str()) {
panic!("deliberate test panic in {}", base.title);
}
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,
superseded: false,
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 {
generated: c.generated.into_iter().collect(),
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,
shuffle: 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,
shuffle: 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,
shuffle: 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 a_qualified_reference_reaches_past_the_last_writer() {
let fake = Fake::new(&[
(
"login_a",
Canned {
status: 200,
captures: vec![("token".into(), "A".into())],
..Default::default()
},
),
(
"login_b",
Canned {
status: 200,
captures: vec![("token".into(), "B".into())],
..Default::default()
},
),
(
"api",
Canned {
status: 200,
..Default::default()
},
),
]);
let entries = [
entry("login_a", &[]),
entry("login_b", &[]),
entry("api", &[]),
];
let res = run(
concat!(
"REQUEST login_a AS first\n",
"REQUEST login_b AS second\n",
"REPORT REQUEST api USING(header.X-First = \"{{first.token}}\", ",
"header.X-Last = \"{{token}}\")\n",
),
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
let sent = fake.sent_entry("api").expect("api was sent");
let header = |k: &str| {
sent.headers
.iter()
.find(|h| h.key == k)
.map(|h| h.value.clone())
};
assert_eq!(header("X-First"), Some("A".into()));
assert_eq!(header("X-Last"), Some("B".into()));
}
#[test]
fn a_qualified_name_is_never_handed_to_hurl() {
let fake = Fake::new(&[
(
"login_a",
Canned {
status: 200,
captures: vec![("token".into(), "A".into())],
..Default::default()
},
),
(
"api",
Canned {
status: 200,
..Default::default()
},
),
]);
let entries = [entry("login_a", &[]), entry("api", &[])];
run(
"REQUEST login_a AS first\nREPORT REQUEST api\n",
&entries,
&[],
&[],
&fake,
);
let vars = fake.call_vars("api");
assert_eq!(vars.get("token"), Some(&"A".to_string()));
assert!(
vars.keys().all(|k| !k.contains('.')),
"dotted names leaked to the runner: {:?}",
vars.keys().collect::<Vec<_>>()
);
}
fn graph_entry(title: &str, captures: &[&str], url_vars: &[&str]) -> HurlEntry {
HurlEntry {
title: title.into(),
method: "GET".into(),
url: format!(
"http://x/{}",
url_vars
.iter()
.map(|v| format!("{{{{{v}}}}}"))
.collect::<Vec<_>>()
.join("/")
),
captures: captures
.iter()
.map(|c| ((*c).to_string(), "jsonpath \"$.t\"".to_string()))
.collect(),
..Default::default()
}
}
fn ok(title: &str) -> (&str, Canned) {
(
title,
Canned {
status: 200,
captures: Vec::new(),
..Default::default()
},
)
}
fn ok_capturing<'a>(title: &'a str, caps: &[(&str, &str)]) -> (&'a str, Canned) {
(
title,
Canned {
status: 200,
captures: caps
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
..Default::default()
},
)
}
#[test]
fn a_cleanup_follows_the_capture_that_actually_won() {
let entries = [
graph_entry("first", &["sid"], &[]),
graph_entry("second", &["sid"], &[]),
graph_entry("purge", &[], &["sid"]),
];
let fake = Fake::new(&[failing("first"), ok_capturing("second", &[("sid", "B")])]);
run(
"REQUEST first AS first\nREQUEST second AS second\nCLEANUP purge\n",
&entries,
&[],
&[],
&fake,
);
assert!(
fake.call_order().contains(&"purge".to_string()),
"the teardown must still run: {:?}",
fake.call_order()
);
assert_eq!(
fake.call_vars("purge").get("sid").map(String::as_str),
Some("B"),
"and with the value it was ordered against"
);
}
#[test]
fn a_cleanup_is_gated_on_whichever_step_wrote_the_value_it_gets() {
let entries = [
graph_entry("first", &["sid"], &[]),
graph_entry("second", &["sid"], &[]),
graph_entry("purge", &[], &["sid"]),
];
let fake = Fake::new(&[
ok_capturing("first", &[("sid", "A")]),
(
"second",
Canned {
status: 500,
error: Some("boom".into()),
captures: vec![("sid".into(), "B".into())],
..Default::default()
},
),
]);
let res = run(
"REQUEST first AS first\nREQUEST second AS second\nCLEANUP purge\n",
&entries,
&[],
&[],
&fake,
);
assert!(
!fake.call_order().contains(&"purge".to_string()),
"the teardown must not run against a failed step's value: {:?}",
fake.call_order()
);
assert!(res.skipped.iter().any(|s| s.contains("purge")));
}
#[test]
fn a_teardown_is_gated_on_the_sibling_that_writes_the_name_last() {
let entries = [
graph_entry("open", &["sid"], &[]),
graph_entry("purge", &[], &["sid"]),
graph_entry("rotate", &["sid"], &[]),
];
let fake = Fake::new(&[
ok_capturing("open", &[("sid", "S1")]),
(
"rotate",
Canned {
status: 500,
error: Some("boom".into()),
captures: vec![("sid".into(), "S2".into())],
..Default::default()
},
),
]);
let res = run(
"REQUEST open AS open\nCLEANUP purge\nCLEANUP rotate DEPENDS open\n",
&entries,
&[],
&[],
&fake,
);
assert!(
!fake.call_order().contains(&"purge".to_string()),
"it would have been sent the failed session: {:?}",
fake.call_vars("purge")
);
assert!(res.skipped.contains(&"purge".to_string()));
}
#[test]
fn a_skipped_sibling_does_not_strand_a_resource_that_was_really_made() {
let entries = [
graph_entry("open", &["sid"], &[]),
graph_entry("flake", &[], &[]),
graph_entry("purge", &[], &["sid"]),
graph_entry("rotate", &["sid"], &[]),
];
let fake = Fake::new(&[ok_capturing("open", &[("sid", "S1")]), failing("flake")]);
run(
"REQUEST open AS open\nREQUEST flake AS flake\n\
CLEANUP purge\nCLEANUP rotate DEPENDS flake\n",
&entries,
&[],
&[],
&fake,
);
assert!(
fake.call_order().contains(&"purge".to_string()),
"the session it tears down was really made: {:?}",
fake.call_order()
);
assert_eq!(
fake.call_vars("purge").get("sid").map(String::as_str),
Some("S1")
);
}
#[test]
fn a_sibling_that_declares_a_name_but_writes_nothing_does_not_vouch_for_it() {
let entries = [
graph_entry("open", &["sid"], &[]),
graph_entry("purge", &[], &["sid"]),
graph_entry("rotate", &["sid"], &[]),
];
let fake = Fake::new(&[
(
"open",
Canned {
status: 500,
error: Some("boom".into()),
captures: vec![("sid".into(), "S1".into())],
..Default::default()
},
),
("rotate", Canned::default()),
]);
let res = run(
"REQUEST open AS open\nCLEANUP purge\nCLEANUP rotate\n",
&entries,
&[],
&[],
&fake,
);
assert!(
!fake.call_order().contains(&"purge".to_string()),
"it would have been sent the failed step's session: {:?}",
fake.call_vars("purge")
);
assert!(res.skipped.contains(&"purge".to_string()));
}
#[test]
fn a_role_named_through_a_loop_variable_collapses_every_iteration() {
let entries = [graph_entry("r", &[], &[])];
let fake = Fake::new(&[]);
let res = run(
"LIST REGIONS=[\"eu\",\"us\"]\n\
FOR R IN REGIONS\n\
\x20 FOR T IN ENVS BASELINE(\"prod-{{R}}\"), COMPARISON(\"staging-{{R}}\")\n\
\x20 REPORT REQUEST r SHOW(HttpStatus)\n\
\x20 END\n\
END\n",
&entries,
&[],
&[
("prod-eu", &[][..]),
("staging-eu", &[][..]),
("prod-us", &[][..]),
("staging-us", &[][..]),
],
&fake,
);
assert!(
res.errors.is_empty(),
"every environment was loaded: {:?}",
res.errors
);
assert_eq!(
res.rows.len(),
2,
"one collapsed row per region: {:?}",
res.rows
.iter()
.map(|r| r.target.clone())
.collect::<Vec<_>>()
);
for row in &res.rows {
let verdict = &row.cells[crate::report::compare::RESULT_COLUMN];
assert!(
verdict.contains("matched"),
"{:?} was not collapsed: {verdict}",
row.target
);
}
}
#[test]
fn a_plain_envs_row_is_not_dragged_into_someone_elses_comparison() {
let entries = [graph_entry("r", &[], &[])];
let fake = Fake::new(&[]);
let res = run(
"FOR E IN ENVS \"prod\", \"staging\"\n\
\x20 REPORT REQUEST r AS proc SHOW(HttpStatus)\n\
\x20 REPORT \"{{who}}\" AS \"proc.v\"\n\
END\n\
FOR T IN ENVS BASELINE(\"prod\"), COMPARISON(\"staging\")\n\
\x20 REPORT REQUEST r AS proc2 SHOW(HttpStatus)\n\
\x20 REPORT \"{{who}}\" AS \"proc2.v\"\n\
END\n",
&entries,
&[],
&[
("prod", &[("who", "P")][..]),
("staging", &[("who", "S")][..]),
],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
let plain: Vec<&ReportRow> = res
.rows
.iter()
.filter(|r| r.cells.contains_key("proc.v"))
.collect();
assert_eq!(plain.len(), 2, "both plain rows survive: {:?}", res.rows);
for row in plain {
assert!(
!row.cells
.contains_key(crate::report::compare::RESULT_COLUMN),
"{:?} compares against nothing: {:?}",
row.target,
row.cells
);
}
}
#[test]
fn an_environment_that_is_a_baseline_once_is_still_a_candidate_elsewhere() {
let entries = [graph_entry("r", &[], &[])];
let fake = Fake::new(&[]);
let res = run(
"FOR (A, B) IN [(\"v1\", \"v2\"), (\"v2\", \"v3\")]\n\
\x20 FOR T IN ENVS BASELINE(\"{{A}}\"), COMPARISON(\"{{B}}\")\n\
\x20 REPORT REQUEST r AS proc SHOW(HttpStatus)\n\
\x20 REPORT \"{{who}}\" AS \"proc.v\"\n\
\x20 END\n\
END\n",
&entries,
&[],
&[
("v1", &[("who", "V1")][..]),
("v2", &[("who", "V2")][..]),
("v3", &[("who", "V3")][..]),
],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
let verdicts: Vec<(Option<String>, String)> = res
.rows
.iter()
.map(|r| {
(
r.target.clone(),
r.cells
.get(crate::report::compare::RESULT_COLUMN)
.cloned()
.unwrap_or_default(),
)
})
.collect();
assert_eq!(verdicts.len(), 2, "one row per pair: {verdicts:?}");
for (target, verdict) in &verdicts {
assert!(
verdict.contains("(baseline)"),
"{target:?} must be diffed against its own pair's baseline: {verdict}"
);
}
}
#[test]
fn a_plain_envs_loop_around_a_comparison_keeps_each_pair_apart() {
let entries = [graph_entry("r", &[], &[])];
let fake = Fake::new(&[]);
let res = run(
"FOR E IN ENVS \"a\", \"b\"\n\
\x20 FOR T IN ENVS BASELINE(\"{{E}}-prod\"), COMPARISON(\"{{E}}-stg\")\n\
\x20 REPORT REQUEST r AS proc SHOW(HttpStatus)\n\
\x20 REPORT \"{{who}}\" AS \"proc.v\"\n\
\x20 END\n\
END\n",
&entries,
&[],
&[
("a", &[("who", "A")][..]),
("b", &[("who", "B")][..]),
("a-prod", &[("who", "A-PROD")][..]),
("a-stg", &[("who", "A-STG")][..]),
("b-prod", &[("who", "B-PROD")][..]),
("b-stg", &[("who", "B-STG")][..]),
],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
let verdicts: Vec<(Option<String>, String)> = res
.rows
.iter()
.map(|r| {
(
r.target.clone(),
r.cells[crate::report::compare::RESULT_COLUMN].clone(),
)
})
.collect();
assert_eq!(
verdicts.len(),
2,
"one collapsed row per pair: {verdicts:?}"
);
for (target, verdict) in &verdicts {
let env = target.as_deref().unwrap_or_default();
let own = format!("{}-prod (baseline)", &env[..1]);
assert!(
verdict.contains(&own),
"{env} must be measured against {own}: {verdict}"
);
}
}
#[test]
fn a_plain_envs_loop_inside_a_comparison_does_not_erase_it() {
let entries = [graph_entry("r", &[], &[])];
let fake = Fake::new(&[]);
let res = run(
"FOR T IN ENVS BASELINE(\"prod\"), COMPARISON(\"stg\")\n\
\x20 FOR E IN ENVS \"au\", \"eu\"\n\
\x20 REPORT REQUEST r AS proc SHOW(HttpStatus)\n\
\x20 REPORT \"{{T}}-{{E}}\" AS \"proc.v\"\n\
\x20 END\n\
END\n",
&entries,
&[],
&[
("prod", &[][..]),
("stg", &[][..]),
("au", &[][..]),
("eu", &[][..]),
],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
let verdicts: Vec<String> = res
.rows
.iter()
.filter_map(|r| r.cells.get(crate::report::compare::RESULT_COLUMN).cloned())
.collect();
assert_eq!(
verdicts.len(),
2,
"one collapsed row per inner environment: {:?}",
res.rows
);
for v in &verdicts {
assert!(
v.contains("prod (baseline)"),
"the enclosing comparison still happened: {v}"
);
}
}
#[test]
fn two_comparisons_in_one_flow_do_not_share_a_baseline() {
let entries = [graph_entry("r", &[], &[])];
let fake = Fake::new(&[]);
let res = run(
"FOR T IN ENVS BASELINE(\"prod-a\"), COMPARISON(\"stg-a\")\n\
\x20 REPORT REQUEST r AS proc SHOW(HttpStatus)\n\
\x20 REPORT \"{{who}}\" AS \"proc.v\"\n\
END\n\
FOR T IN ENVS BASELINE(\"prod-b\"), COMPARISON(\"stg-b\")\n\
\x20 REPORT REQUEST r AS proc SHOW(HttpStatus)\n\
\x20 REPORT \"{{who}}\" AS \"proc.v\"\n\
END\n",
&entries,
&[],
&[
("prod-a", &[("who", "A1")][..]),
("stg-a", &[("who", "A2")][..]),
("prod-b", &[("who", "B1")][..]),
("stg-b", &[("who", "B2")][..]),
],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
let verdicts: Vec<(String, String)> = res
.rows
.iter()
.filter_map(|r| {
Some((
r.target.clone()?,
r.cells.get(crate::report::compare::RESULT_COLUMN)?.clone(),
))
})
.collect();
assert_eq!(verdicts.len(), 2, "one per comparison: {verdicts:?}");
for (target, verdict) in &verdicts {
let own = format!("prod-{} (baseline)", &target[target.len() - 1..]);
assert!(
verdict.contains(&own),
"{target} must be measured against {own}: {verdict}"
);
}
}
#[test]
fn a_role_named_through_a_capture_is_the_one_the_collapse_looks_for() {
let entries = [
graph_entry("setup", &["stack"], &[]),
graph_entry("r", &[], &[]),
];
let fake = Fake::new(&[ok_capturing("setup", &[("stack", "prod")])]);
let res = run(
"REQUEST setup AS setup\n\
FOR T IN ENVS BASELINE(\"{{setup.stack}}\"), COMPARISON(\"staging\")\n\
\x20 REPORT REQUEST r SHOW(HttpStatus)\n\
END\n",
&entries,
&[],
&[("prod", &[][..]), ("staging", &[][..])],
&fake,
);
assert_eq!(
res.role_targets.get("{{setup.stack}}"),
Some(&vec!["prod".to_string()]),
"the run must record what it resolved: {:?}",
res.role_targets
);
assert!(
res.errors.is_empty(),
"both environments were loaded: {:?}",
res.errors
);
assert_eq!(res.rows.len(), 1, "the baseline row was not collapsed in");
assert_eq!(res.rows[0].target.as_deref(), Some("staging"));
let verdict = &res.rows[0].cells[crate::report::compare::RESULT_COLUMN];
assert!(
verdict.contains("matched"),
"the collapse looked for the literal role name: {verdict}"
);
}
#[test]
fn a_cleanup_downstream_of_a_ring_is_not_sent_with_an_unresolved_reference() {
let mut a = graph_entry("a", &["tok"], &["sid"]);
a.title = "a".into();
let mut b = graph_entry("b", &["sid"], &["tok"]);
b.title = "b".into();
let c = graph_entry("c", &[], &["a.tok"]);
let entries = [a, b, c];
let fake = Fake::new(&[]);
let res = run(
"CLEANUP a\nCLEANUP b\nCLEANUP c\n",
&entries,
&[],
&[],
&fake,
);
assert!(
!fake.call_order().contains(&"c".to_string()),
"c depends on ring member 'a' and must be skipped, not sent",
);
assert!(
res.skipped.contains(&"c".to_string()),
"c must be reported as skipped: {:?}",
res.skipped
);
}
#[test]
fn a_cleanup_two_hops_from_a_ring_is_not_sent_with_an_unresolved_reference() {
let mut a = graph_entry("a", &["tok"], &["sid"]);
a.title = "a".into();
let mut b = graph_entry("b", &["sid"], &["tok"]);
b.title = "b".into();
let mut d = graph_entry("d", &["dkey"], &["a.tok"]);
d.title = "d".into();
let e = graph_entry("e", &[], &["d.dkey"]);
let entries = [a, b, d, e];
let fake = Fake::new(&[]);
let res = run(
"CLEANUP a\nCLEANUP b\nCLEANUP d\nCLEANUP e\n",
&entries,
&[],
&[],
&fake,
);
assert!(
!fake.call_order().contains(&"e".to_string()),
"e follows d, which the ring refused: {:?}",
fake.call_order()
);
assert!(
res.skipped.contains(&"e".to_string()),
"e must be reported as skipped: {:?}",
res.skipped
);
}
#[test]
fn a_flat_reference_to_a_ring_members_capture_does_not_fire_at_an_older_value() {
let old = graph_entry("old", &["sid", "tok"], &[]);
let mut a = graph_entry("a", &["tok"], &["sid"]);
a.title = "a".into();
let mut b = graph_entry("b", &["sid"], &["tok"]);
b.title = "b".into();
let c = graph_entry("c", &[], &["tok"]);
let entries = [old, a, b, c];
let fake = Fake::new(&[ok_capturing(
"old",
&[("sid", "OLD_SID"), ("tok", "OLD_TOK")],
)]);
let res = run(
"REQUEST old AS old\nCLEANUP a\nCLEANUP b\nCLEANUP c\n",
&entries,
&[],
&[],
&fake,
);
assert!(
!fake.call_order().contains(&"c".to_string()),
"c reads a value only the refused ring could write: {:?}",
fake.call_order()
);
assert!(
res.skipped.contains(&"c".to_string()),
"c must be reported as skipped: {:?}",
res.skipped
);
}
#[test]
fn a_ring_of_cleanups_is_skipped_not_fired_at_stale_values() {
let old = graph_entry("old", &["sid", "tok"], &[]);
let mut a = graph_entry("a", &["tok"], &["sid"]);
a.title = "a".into();
let mut b = graph_entry("b", &["sid"], &["tok"]);
b.title = "b".into();
let entries = [old, a, b];
let fake = Fake::new(&[ok_capturing(
"old",
&[("sid", "OLD_SID"), ("tok", "OLD_TOK")],
)]);
let res = run(
"REQUEST old AS old\nCLEANUP a\nCLEANUP b\n",
&entries,
&[],
&[],
&fake,
);
for m in ["a", "b"] {
assert!(
!fake.call_order().contains(&m.to_string()),
"{m} was fired at a stale value: {:?}",
fake.call_vars(m)
);
assert!(res.skipped.contains(&m.to_string()), "{m} must be skipped");
}
}
#[test]
fn a_cleanup_between_two_rings_is_named_in_neither() {
let mut a = graph_entry("a", &["token"], &["sid"]);
a.title = "a".into();
let mut b = graph_entry("b", &["sid"], &["token"]);
b.title = "b".into();
let d = graph_entry("d", &["dkey"], &[]);
let mut e = graph_entry("e", &["tok2"], &["sid2", "dkey"]);
e.title = "e".into();
let mut f = graph_entry("f", &["sid2"], &["tok2"]);
f.title = "f".into();
let entries = [a, b, d, e, f];
let fake = Fake::new(&[]);
let res = run(
"CLEANUP a\nCLEANUP b\nCLEANUP d DEPENDS a\nCLEANUP e\nCLEANUP f\n",
&entries,
&[],
&[],
&fake,
);
let ring = res
.errors
.iter()
.find(|e| e.contains("cycle"))
.expect("the rings are reported");
for m in ["a", "b", "e", "f"] {
assert!(ring.contains(m), "{m} is in a ring: {ring}");
}
assert!(
!ring.contains(", d") && !ring.contains("d,"),
"'d' sits between the two rings and is in neither: {ring}"
);
}
#[test]
fn a_cycle_among_cleanups_names_only_the_ones_in_it() {
let mut a = graph_entry("a", &["token"], &["sid"]);
a.title = "a".into();
let mut b = graph_entry("b", &["sid"], &["token"]);
b.title = "b".into();
let c = graph_entry("c", &[], &[]);
let entries = [a, b, c];
let fake = Fake::new(&[]);
let res = run(
"CLEANUP a\nCLEANUP b\nCLEANUP c DEPENDS a\n",
&entries,
&[],
&[],
&fake,
);
let ring = res
.errors
.iter()
.find(|e| e.contains("cycle"))
.expect("the ring is reported");
assert!(ring.contains('a') && ring.contains('b'), "{ring}");
assert!(
!ring.contains(", c") && !ring.contains("c,"),
"'c' only depends on the ring, it is not in it: {ring}"
);
}
#[test]
fn a_sibling_cleanup_that_writes_the_name_is_what_the_teardown_waits_for() {
let entries = [
graph_entry("purge", &[], &["sid"]),
graph_entry("rotate", &["sid"], &[]),
];
let fake = Fake::new(&[ok_capturing("rotate", &[("sid", "FRESH")])]);
run(
"CLEANUP rotate\nCLEANUP purge\n",
&entries,
&[("sid", "from-env")],
&[],
&fake,
);
let order = fake.call_order();
assert_eq!(order, vec!["rotate".to_string(), "purge".to_string()]);
assert_eq!(
fake.call_vars("purge").get("sid").map(String::as_str),
Some("FRESH"),
"the teardown is sent the value it was ordered against"
);
}
#[test]
fn a_teardown_is_not_sent_against_a_sibling_that_failed_to_write() {
let entries = [
graph_entry("purge", &[], &["sid"]),
graph_entry("rotate", &["sid"], &[]),
];
let fake = Fake::new(&[failing("rotate")]);
let res = run(
"CLEANUP purge\nCLEANUP rotate\n",
&entries,
&[("sid", "from-env")],
&[],
&fake,
);
assert!(
!fake.call_order().contains(&"purge".to_string()),
"it would have torn down whatever the environment happened to name: {:?}",
fake.call_order()
);
assert!(res.skipped.contains(&"purge".to_string()));
}
#[test]
fn a_ring_of_cleanups_is_an_error_not_a_silent_skip() {
let mut a = graph_entry("a", &["token"], &["sid"]);
a.title = "a".into();
let mut b = graph_entry("b", &["sid"], &["token"]);
b.title = "b".into();
let entries = [a, b];
let fake = Fake::new(&[]);
let res = run("CLEANUP a\nCLEANUP b\n", &entries, &[], &[], &fake);
assert!(
res.errors
.iter()
.any(|e| e.contains("a") && e.contains("b")),
"the ring has to be said out loud: {:?}",
res.errors
);
}
#[test]
fn a_cleanup_that_reads_another_cleanups_capture_runs_after_it() {
let entries = [
graph_entry("setup", &[], &[]),
graph_entry("parent", &["token"], &[]),
graph_entry("child", &[], &[]),
];
let fake = Fake::new(&[ok_capturing("parent", &[("token", "T")])]);
run(
"REQUEST setup\n\
CLEANUP parent DEPENDS setup\n\
CLEANUP child DEPENDS setup USING(query.token = \"{{parent.token}}\")\n",
&entries,
&[],
&[],
&fake,
);
let order = fake.call_order();
let at = |n: &str| order.iter().position(|s| s == n);
assert!(
at("parent") < at("child"),
"the cleanup holding the value must run first: {order:?}"
);
}
#[test]
fn a_failed_sole_producer_still_skips_the_cleanup() {
let entries = [
graph_entry("create", &["sid"], &[]),
graph_entry("purge", &[], &["sid"]),
];
let fake = Fake::new(&[failing("create")]);
let res = run("REQUEST create\nCLEANUP purge\n", &entries, &[], &[], &fake);
assert!(!fake.call_order().contains(&"purge".to_string()));
assert!(res.skipped.iter().any(|s| s.contains("purge")));
}
#[test]
fn a_cleanup_that_depends_on_another_cleanup_runs_after_it() {
let entries = [
graph_entry("setup", &[], &[]),
graph_entry("c1", &[], &[]),
graph_entry("c2", &[], &[]),
];
let fake = Fake::new(&[]);
let res = run(
"REQUEST setup\nCLEANUP c1 DEPENDS setup\nCLEANUP c2 DEPENDS setup, c1\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.skipped.is_empty(), "{:?}", res.skipped);
let order = fake.call_order();
let c1 = order.iter().position(|t| t == "c1").expect("c1 ran");
let c2 = order.iter().position(|t| t == "c2").expect("c2 ran");
assert!(c1 < c2, "{order:?}");
}
#[test]
fn a_dotted_environment_variable_cannot_answer_a_step_reference() {
let entries = [
graph_entry("producer", &["token"], &[]),
graph_entry("consumer", &[], &[]),
];
let fake = Fake::new(&[failing("producer")]);
run(
"REQUEST producer AS login\nREQUEST consumer USING(url = \"http://x/{{login.token}}\")\n",
&entries,
&[("login.token", "STALE")],
&[],
&fake,
);
let sent = fake.sent_entry("consumer").expect("consumer was sent");
assert!(
!sent.url.contains("STALE"),
"the environment must not stand in for a capture: {}",
sent.url
);
}
#[test]
fn wrapping_changes_what_happens_after_a_failure_and_that_is_the_point() {
let entries = [
graph_entry("producer", &["token"], &[]),
graph_entry("consumer", &[], &["token"]),
];
let canned = [failing("producer")];
let flat_fake = Fake::new(&canned);
run(
"REPORT REQUEST producer\nREPORT REQUEST consumer\n",
&entries,
&[],
&[],
&flat_fake,
);
assert_eq!(flat_fake.call_order(), ["producer", "consumer"]);
let wrapped_fake = Fake::new(&canned);
let wrapped = run(
"GRAPH\n REPORT REQUEST producer\n REPORT REQUEST consumer\nEND\n",
&entries,
&[],
&[],
&wrapped_fake,
);
assert_eq!(
wrapped_fake.call_order(),
["producer"],
"the consumer must not be sent"
);
assert!(
wrapped.skipped.iter().any(|s| s.contains("consumer")),
"and it must be reported as skipped: {:?}",
wrapped.skipped
);
}
#[test]
fn wrapping_a_sequential_block_in_a_region_changes_nothing() {
let entries = [
graph_entry("a", &[], &[]),
graph_entry("b", &[], &[]),
graph_entry("c", &[], &[]),
];
let canned = [ok("a"), ok("b"), ok("c")];
let plain = Fake::new(&canned);
let flat = run(
"REQUEST a\nREPORT REQUEST b\nREQUEST c\n",
&entries,
&[],
&[],
&plain,
);
let wrapped_fake = Fake::new(&canned);
let wrapped = run(
"GRAPH\n REQUEST a\n REPORT REQUEST b\n REQUEST c\nEND\n",
&entries,
&[],
&[],
&wrapped_fake,
);
assert_eq!(flat.rows.len(), wrapped.rows.len());
assert_eq!(flat.rows[0].cells, wrapped.rows[0].cells);
assert_eq!(plain.call_order(), wrapped_fake.call_order());
}
fn run_shuffled(src: &str, entries: &[HurlEntry], fake: &Fake, seed: u64) -> 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: Default::default(),
sink: None,
shuffle: Some(seed),
};
run_flow(&flow, &ctx)
}
#[test]
fn a_step_that_panics_ends_the_run_instead_of_hanging_it() {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let entries = [
graph_entry("a", &[], &[]),
graph_entry("b", &[], &[]),
graph_entry("c", &[], &[]),
graph_entry("d", &[], &[]),
];
let fake = Fake::new(&[]).with_delay(20).panicking_on("a");
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run(
"PARALLEL(2) GRAPH\n REPORT REQUEST a\n REPORT REQUEST b\n REPORT REQUEST c\n REPORT REQUEST d\nEND\n",
&entries,
&[],
&[],
&fake,
)
}));
let _ = tx.send(outcome.is_err());
});
match rx.recv_timeout(std::time::Duration::from_secs(20)) {
Ok(panicked) => assert!(
panicked,
"the panic must reach the caller, not be swallowed"
),
Err(_) => panic!("the region hung instead of failing"),
}
}
#[test]
fn a_parallel_region_overlaps_steps_that_do_not_depend_on_each_other() {
let entries = [
graph_entry("a", &[], &[]),
graph_entry("b", &[], &[]),
graph_entry("c", &[], &[]),
graph_entry("d", &[], &[]),
];
let fake = Fake::new(&[ok("a"), ok("b"), ok("c"), ok("d")]).with_delay(40);
let res = run(
"PARALLEL(4) GRAPH\n REQUEST a\n REQUEST b\n REQUEST c\n REQUEST d\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(fake.call_count(), 4);
assert!(
fake.peak_concurrency() > 1,
"a degree of 4 over four independent steps ran them one at a time"
);
}
#[test]
fn a_degree_never_overlaps_a_step_with_the_one_it_depends_on() {
let entries = [
graph_entry("login", &["token"], &[]),
graph_entry("order", &["id"], &["token"]),
graph_entry("fetch", &[], &["id"]),
];
let fake = Fake::new(&[
ok_capturing("login", &[("token", "T")]),
ok_capturing("order", &[("id", "7")]),
ok("fetch"),
])
.with_delay(20);
let res = run(
"PARALLEL(4) GRAPH\n REQUEST fetch\n REQUEST order\n REQUEST login\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(fake.peak_concurrency(), 1, "a chain cannot be overlapped");
assert_eq!(
fake.call_order(),
vec!["login".to_string(), "order".into(), "fetch".into()],
"and it still runs in dependency order, not written order"
);
}
#[test]
fn a_degree_does_not_change_what_a_region_reports() {
let entries = [
graph_entry("login", &["token"], &[]),
graph_entry("a", &[], &["token"]),
graph_entry("b", &[], &["token"]),
];
let canned = [ok_capturing("login", &[("token", "T")]), ok("a"), ok("b")];
let body = " REQUEST login\n REPORT REQUEST a\n REPORT REQUEST b\nEND\n";
let one = Fake::new(&canned);
let seq = run(&format!("GRAPH\n{body}"), &entries, &[], &[], &one);
let many = Fake::new(&canned);
let par = run(
&format!("PARALLEL(4) GRAPH\n{body}"),
&entries,
&[],
&[],
&many,
);
assert_eq!(seq.rows.len(), par.rows.len());
assert_eq!(seq.rows[0].cells, par.rows[0].cells);
assert_eq!(
seq.column_order, par.column_order,
"column order must not depend on which worker finished first"
);
}
#[test]
fn a_parallel_region_still_skips_a_step_whose_dependency_failed() {
let entries = [
graph_entry("login", &["token"], &[]),
graph_entry("order", &[], &["token"]),
graph_entry("other", &[], &[]),
];
let fake = Fake::new(&[failing("login"), ok("order"), ok("other")]);
let res = run(
"PARALLEL(4) GRAPH\n REQUEST login\n REPORT REQUEST order\n REQUEST other\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(res.skipped, vec!["order".to_string()]);
assert!(
!fake.call_order().contains(&"order".to_string()),
"a skipped step must not be sent: {:?}",
fake.call_order()
);
assert!(
fake.call_order().contains(&"other".to_string()),
"an unrelated step is not collateral damage"
);
}
#[test]
fn the_same_shuffle_seed_reproduces_the_same_order() {
let entries = [
graph_entry("a", &[], &[]),
graph_entry("b", &[], &[]),
graph_entry("c", &[], &[]),
graph_entry("d", &[], &[]),
];
let canned = [ok("a"), ok("b"), ok("c"), ok("d")];
let src = "GRAPH\n REQUEST a\n REQUEST b\n REQUEST c\n REQUEST d\nEND\n";
let one = Fake::new(&canned);
run_shuffled(src, &entries, &one, 12345);
let two = Fake::new(&canned);
run_shuffled(src, &entries, &two, 12345);
assert_eq!(one.call_order(), two.call_order());
}
#[test]
fn shuffling_varies_the_order_among_steps_that_may_run_in_any_order() {
let entries = [
graph_entry("a", &[], &[]),
graph_entry("b", &[], &[]),
graph_entry("c", &[], &[]),
graph_entry("d", &[], &[]),
];
let canned = [ok("a"), ok("b"), ok("c"), ok("d")];
let src = "GRAPH\n REQUEST a\n REQUEST b\n REQUEST c\n REQUEST d\nEND\n";
let written = vec!["a".to_string(), "b".into(), "c".into(), "d".into()];
let plain = Fake::new(&canned);
run(src, &entries, &[], &[], &plain);
assert_eq!(plain.call_order(), written, "the default is written order");
let varied = (1..40u64).any(|seed| {
let f = Fake::new(&canned);
run_shuffled(src, &entries, &f, seed);
f.call_order() != written
});
assert!(varied, "no seed in 39 varied the order of four free steps");
}
#[test]
fn shuffling_never_breaks_a_dependency() {
let entries = [
graph_entry("login", &["token"], &[]),
graph_entry("free", &[], &[]),
graph_entry("order", &[], &["token"]),
];
let canned = [
ok_capturing("login", &[("token", "T")]),
ok("free"),
ok("order"),
];
let src = "GRAPH\n REQUEST login\n REQUEST free\n REQUEST order\nEND\n";
for seed in 1..30u64 {
let f = Fake::new(&canned);
let res = run_shuffled(src, &entries, &f, seed);
assert!(res.errors.is_empty(), "seed {seed}: {:?}", res.errors);
let order = f.call_order();
let li = order.iter().position(|t| t == "login").unwrap();
let oi = order.iter().position(|t| t == "order").unwrap();
assert!(li < oi, "seed {seed} ran order before login: {order:?}");
}
}
#[test]
fn a_region_runs_a_producer_written_below_its_consumer_first() {
let entries = [
graph_entry("api", &[], &["token"]),
graph_entry("login", &["token"], &[]),
];
let fake = Fake::new(&[ok("api"), ok_capturing("login", &[("token", "T")])]);
let res = run(
"GRAPH\n REQUEST api\n REQUEST login\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(fake.call_order(), ["login", "api"]);
assert_eq!(fake.call_vars("api").get("token"), Some(&"T".to_string()));
}
#[test]
fn a_step_in_a_region_is_not_handed_a_non_ancestors_captures() {
let entries = [
graph_entry("side", &["secret"], &[]),
graph_entry("api", &[], &[]),
];
let fake = Fake::new(&[ok_capturing("side", &[("secret", "S")]), ok("api")]);
let res = run(
"GRAPH\n REQUEST side\n REQUEST api\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(fake.call_vars("api").get("secret"), None);
let loose = Fake::new(&[ok_capturing("side", &[("secret", "S")]), ok("api")]);
run("REQUEST side\nREQUEST api\n", &entries, &[], &[], &loose);
assert_eq!(loose.call_vars("api").get("secret"), Some(&"S".to_string()));
}
#[test]
fn a_regions_captures_are_all_visible_after_it() {
let entries = [
graph_entry("side", &["secret"], &[]),
graph_entry("api", &[], &[]),
graph_entry("after", &[], &["secret"]),
];
let fake = Fake::new(&[
ok_capturing("side", &[("secret", "S")]),
ok("api"),
ok("after"),
]);
run(
"GRAPH\n REQUEST side\n REQUEST api\nEND\nREQUEST after\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(
fake.call_vars("after").get("secret"),
Some(&"S".to_string())
);
}
#[test]
fn an_unorderable_region_sends_nothing() {
let entries = [
graph_entry("a", &["x"], &["y"]),
graph_entry("b", &["y"], &["x"]),
];
let fake = Fake::new(&[ok("a"), ok("b")]);
let res = run(
"GRAPH\n REQUEST a\n REQUEST b\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(fake.call_count(), 0);
assert!(!res.errors.is_empty());
}
fn failing(title: &str) -> (&str, Canned) {
(
title,
Canned {
status: 500,
error: Some("boom".into()),
..Default::default()
},
)
}
fn gen_entry(title: &str, generators: &[&str], url_vars: &[&str]) -> HurlEntry {
HurlEntry {
generators: generators
.iter()
.map(|g| ((*g).to_string(), "uuid()".to_string()))
.collect(),
..graph_entry(title, &[], url_vars)
}
}
fn ok_generating<'a>(title: &'a str, gens: &[(&str, &str)]) -> (&'a str, Canned) {
(
title,
Canned {
status: 200,
generated: gens
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
..Default::default()
},
)
}
fn failing_generating<'a>(title: &'a str, gens: &[(&str, &str)]) -> (&'a str, Canned) {
(
title,
Canned {
status: 500,
error: Some("boom".into()),
generated: gens
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
..Default::default()
},
)
}
#[test]
fn a_generated_value_orders_a_region_and_reaches_the_step_that_reads_it() {
let entries = [
graph_entry("fetch", &[], &["sid"]),
gen_entry("create", &["sid"], &[]),
];
let fake = Fake::new(&[ok("fetch"), ok_generating("create", &[("sid", "NEW")])]);
let res = run(
"GRAPH\n REQUEST fetch\n REQUEST create\nEND\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(
fake.call_vars("fetch").get("sid").map(String::as_str),
Some("NEW"),
"the generated value has to reach the step that reads it, not the \
environment's stale one"
);
}
#[test]
fn a_generated_value_reaches_the_steps_that_read_it() {
let entries = [
gen_entry("create", &["sid"], &[]),
graph_entry("fetch", &[], &["sid"]),
graph_entry("show", &[], &[]),
];
let fake = Fake::new(&[
ok_generating("create", &[("sid", "NEW")]),
ok("fetch"),
ok("show"),
]);
let res = run(
"REQUEST create AS setup\n\
REQUEST fetch\n\
REQUEST show USING(query.sid = \"{{setup.sid}}\")\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(
fake.call_vars("fetch").get("sid").map(String::as_str),
Some("NEW")
);
let sent = fake.sent_entry("show").expect("show was sent");
assert_eq!(
sent.queries
.iter()
.find(|q| q.key == "sid")
.map(|q| q.value.as_str()),
Some("NEW"),
"qualified generated value did not reach the send: {:?}",
sent.queries
);
}
#[test]
fn a_cleanup_reading_a_generated_value_waits_for_the_step_that_generated_it() {
let entries = [
gen_entry("create", &["sid"], &[]),
graph_entry("delete", &[], &["sid"]),
];
let fake = Fake::new(&[ok_generating("create", &[("sid", "NEW")]), ok("delete")]);
let res = run(
"REQUEST create AS setup\nCLEANUP delete\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert_eq!(fake.call_order(), ["create", "delete"]);
assert!(res.skipped.is_empty(), "{:?}", res.skipped);
assert_eq!(
fake.call_vars("delete").get("sid").map(String::as_str),
Some("NEW")
);
}
#[test]
fn a_cleanup_still_runs_when_the_step_that_generated_its_value_failed() {
let entries = [
gen_entry("create", &["sid"], &[]),
graph_entry("delete", &[], &["sid"]),
];
let fake = Fake::new(&[
failing_generating("create", &[("sid", "NEW")]),
ok("delete"),
]);
let res = run(
"REQUEST create AS setup\nCLEANUP delete\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert_eq!(fake.call_order(), ["create", "delete"]);
assert!(res.skipped.is_empty(), "{:?}", res.skipped);
assert_eq!(
fake.call_vars("delete").get("sid").map(String::as_str),
Some("NEW")
);
}
fn gen_and_capture_entry(title: &str, name: &str, url_vars: &[&str]) -> HurlEntry {
HurlEntry {
generators: vec![(name.to_string(), "uuid()".to_string())],
..graph_entry(title, &[name], url_vars)
}
}
#[test]
fn a_minted_value_gates_its_cleanup_even_where_a_capture_was_also_declared() {
let entries = [
gen_and_capture_entry("create", "sid", &[]),
graph_entry("delete", &[], &["sid"]),
];
let fake = Fake::new(&[
failing_generating("create", &[("sid", "NEW")]),
ok("delete"),
]);
let res = run(
"REQUEST create AS setup\nCLEANUP delete\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert!(res.skipped.is_empty(), "{:?}", res.skipped);
assert_eq!(fake.call_order(), ["create", "delete"]);
assert_eq!(
fake.call_vars("delete").get("sid").map(String::as_str),
Some("NEW"),
"and with the id it actually minted"
);
}
#[test]
fn a_capture_that_fired_still_gates_its_cleanup_on_the_step_succeeding() {
let entries = [
gen_and_capture_entry("create", "sid", &[]),
graph_entry("delete", &[], &["sid"]),
];
let fake = Fake::new(&[
(
"create",
Canned {
status: 500,
error: Some("boom".into()),
generated: [("sid".to_string(), "NEW".to_string())]
.into_iter()
.collect(),
captures: vec![("sid".into(), "SERVER".into())],
..Default::default()
},
),
ok("delete"),
]);
let res = run(
"REQUEST create AS setup\nCLEANUP delete\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert_eq!(
res.skipped,
["delete"],
"the capture answered, and it failed"
);
assert_eq!(fake.call_order(), ["create"]);
}
#[test]
fn a_minted_value_from_inside_a_region_still_gates_the_cleanup_outside_it() {
let entries = [
gen_and_capture_entry("create", "sid", &[]),
graph_entry("delete", &[], &["sid"]),
];
let fake = Fake::new(&[
failing_generating("create", &[("sid", "NEW")]),
ok("delete"),
]);
let res = run(
"GRAPH\n REQUEST create AS setup\nEND\nCLEANUP delete\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert!(res.skipped.is_empty(), "{:?}", res.skipped);
assert_eq!(
fake.call_vars("delete").get("sid").map(String::as_str),
Some("NEW")
);
}
#[test]
fn a_cleanup_is_skipped_when_the_generated_value_it_reads_was_never_produced() {
let entries = [
gen_entry("create", &["sid"], &[]),
graph_entry("delete", &[], &["sid"]),
];
let fake = Fake::new(&[failing("create"), ok("delete")]);
let res = run(
"REQUEST create AS setup\nCLEANUP delete\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert_eq!(fake.call_order(), ["create"]);
assert_eq!(res.skipped, ["delete"]);
}
#[test]
fn a_reporting_request_threads_its_generated_values_forward_too() {
let entries = [
HurlEntry {
generators: vec![("sid".to_string(), "uuid()".to_string())],
..graph_entry("create", &["sid"], &[])
},
graph_entry("delete", &[], &["sid"]),
];
let fake = Fake::new(&[
(
"create",
Canned {
status: 200,
captures: vec![("sid".to_string(), "SERVER".to_string())],
generated: vec![("sid".to_string(), "MINTED".to_string())],
..Default::default()
},
),
ok("delete"),
]);
run(
"REPORT REQUEST create AS setup SHOW(HttpStatus)\nCLEANUP delete\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert_eq!(
fake.call_vars("delete").get("sid").map(String::as_str),
Some("SERVER")
);
}
#[test]
fn a_captured_name_outranks_the_generated_one_of_the_same_name() {
let entries = [
HurlEntry {
generators: vec![("sid".to_string(), "uuid()".to_string())],
..graph_entry("create", &["sid"], &[])
},
graph_entry("delete", &[], &["sid"]),
];
let fake = Fake::new(&[
(
"create",
Canned {
status: 200,
captures: vec![("sid".to_string(), "SERVER".to_string())],
generated: vec![("sid".to_string(), "MINTED".to_string())],
..Default::default()
},
),
ok("delete"),
]);
let res = run(
"REQUEST create AS setup\nCLEANUP delete\n",
&entries,
&[("sid", "OLD")],
&[],
&fake,
);
assert!(res.skipped.is_empty(), "{:?}", res.skipped);
assert_eq!(
fake.call_vars("delete").get("sid").map(String::as_str),
Some("SERVER")
);
}
#[test]
fn depends_orders_two_steps_that_share_no_data() {
let entries = [graph_entry("fetch", &[], &[]), graph_entry("put", &[], &[])];
let fake = Fake::new(&[ok("fetch"), ok("put")]);
let res = run(
"GRAPH\n REQUEST fetch DEPENDS put\n REQUEST put\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(fake.call_order(), ["put", "fetch"]);
}
#[test]
fn a_skip_propagates_through_a_step_whose_other_dependency_succeeded() {
let entries = [
graph_entry("boom", &["b"], &[]),
graph_entry("good", &["g"], &[]),
graph_entry("mid", &["m"], &["b"]),
graph_entry("join", &[], &["m", "g"]),
];
let fake = Fake::new(&[
failing("boom"),
ok_capturing("good", &[("g", "G")]),
ok_capturing("mid", &[("m", "M")]),
ok("join"),
]);
let res = run(
"PARALLEL(4) GRAPH\n REQUEST boom\n REQUEST good\n REQUEST mid\n REQUEST join\nEND\n",
&entries,
&[],
&[],
&fake,
);
let mut sent = fake.call_order();
sent.sort();
assert_eq!(sent, ["boom", "good"], "the join was sent anyway");
assert_eq!(res.skipped, ["mid", "join"]);
}
#[test]
fn a_failed_step_skips_everything_downstream_of_it() {
let entries = [
graph_entry("a", &["t"], &[]),
graph_entry("b", &["u"], &["t"]),
graph_entry("c", &[], &["u"]),
graph_entry("d", &[], &[]),
];
let fake = Fake::new(&[failing("a"), ok("b"), ok("c"), ok("d")]);
let res = run(
"GRAPH\n REQUEST a\n REQUEST b\n REQUEST c\n REQUEST d\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(fake.call_order(), ["a", "d"]);
assert_eq!(res.skipped, ["b", "c"]);
}
#[test]
fn a_skipped_reported_step_says_so_in_its_row() {
let entries = [graph_entry("a", &["t"], &[]), graph_entry("b", &[], &["t"])];
let fake = Fake::new(&[failing("a"), ok("b")]);
let res = run(
"GRAPH\n REQUEST a\n REPORT REQUEST b\nEND\n",
&entries,
&[],
&[],
&fake,
);
assert_eq!(res.skipped, ["b"]);
let cell = res.rows[0]
.cells
.get("b.Error")
.cloned()
.unwrap_or_default();
assert!(cell.contains("skipped"), "{cell}");
assert!(cell.contains('a'), "{cell}");
}
#[test]
fn a_cleanup_runs_after_the_block_not_where_it_is_written() {
let entries = [
graph_entry("purge", &[], &[]),
graph_entry("a", &[], &[]),
graph_entry("b", &[], &[]),
];
let fake = Fake::new(&[ok("purge"), ok("a"), ok("b")]);
let res = run(
"CLEANUP purge\nREQUEST a\nREQUEST b\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(fake.call_order(), ["a", "b", "purge"]);
}
#[test]
fn cleanups_unwind_in_reverse_order_of_what_they_tear_down() {
let entries = [
graph_entry("login", &["token"], &[]),
graph_entry("open", &["sid"], &["token"]),
graph_entry("revoke", &[], &["token"]),
graph_entry("close", &[], &["sid"]),
];
let fake = Fake::new(&[
ok_capturing("login", &[("token", "T")]),
ok_capturing("open", &[("sid", "S")]),
ok("revoke"),
ok("close"),
]);
let res = run(
"REQUEST login\nREQUEST open\nCLEANUP revoke\nCLEANUP close\n",
&entries,
&[],
&[],
&fake,
);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(fake.call_order(), ["login", "open", "close", "revoke"]);
}
#[test]
fn a_cleanup_survives_a_failure_it_does_not_depend_on() {
let entries = [
graph_entry("login", &["token"], &[]),
graph_entry("work", &[], &["token"]),
graph_entry("revoke", &[], &["token"]),
];
let fake = Fake::new(&[
ok_capturing("login", &[("token", "T")]),
failing("work"),
ok("revoke"),
]);
let res = run(
"REQUEST login\nREQUEST work\nCLEANUP revoke\n",
&entries,
&[],
&[],
&fake,
);
assert!(fake.call_order().contains(&"revoke".to_string()));
assert!(res.skipped.is_empty(), "{:?}", res.skipped);
}
#[test]
fn a_cleanup_is_skipped_when_the_thing_it_tears_down_was_never_built() {
let entries = [
graph_entry("login", &["token"], &[]),
graph_entry("revoke", &[], &["token"]),
];
let fake = Fake::new(&[failing("login"), ok("revoke")]);
let res = run("REQUEST login\nCLEANUP revoke\n", &entries, &[], &[], &fake);
assert_eq!(fake.call_order(), ["login"]);
assert_eq!(res.skipped, ["revoke"]);
assert!(
res.warnings.iter().any(|w| w.contains("revoke")),
"{:?}",
res.warnings
);
}
#[test]
fn a_cleanup_that_needs_nothing_always_runs() {
let entries = [graph_entry("a", &[], &[]), graph_entry("purge", &[], &[])];
let fake = Fake::new(&[failing("a"), ok("purge")]);
let res = run("REQUEST a\nCLEANUP purge\n", &entries, &[], &[], &fake);
assert_eq!(fake.call_order(), ["a", "purge"]);
assert!(res.skipped.is_empty(), "{:?}", res.skipped);
}
#[test]
fn a_failing_cleanup_warns_rather_than_erroring() {
let entries = [graph_entry("a", &[], &[]), graph_entry("purge", &[], &[])];
let fake = Fake::new(&[ok("a"), failing("purge")]);
let res = run("REQUEST a\nCLEANUP purge\n", &entries, &[], &[], &fake);
assert!(res.errors.is_empty(), "{:?}", res.errors);
assert_eq!(res.warnings.len(), 1, "{:?}", res.warnings);
assert!(res.warnings[0].contains("purge"), "{:?}", res.warnings);
}
#[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),
shuffle: None,
};
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_eq!(res.rows[0].key, vec!["au".to_string()]);
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),
shuffle: None,
};
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),
shuffle: None,
};
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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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 {
generated: Default::default(),
entries: vec![EntryOutcome {
entry_index: 0,
superseded: false,
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,
shuffle: 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 {
generated: Default::default(),
entries: vec![EntryOutcome {
entry_index: 0,
superseded: false,
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,
shuffle: 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,
shuffle: 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 {
generated: Default::default(),
entries: vec![EntryOutcome {
entry_index: 0,
superseded: false,
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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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 {
generated: Default::default(),
entries: vec![EntryOutcome {
entry_index: 0,
superseded: false,
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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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,
shuffle: 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 {
generated: Default::default(),
entries: vec![EntryOutcome {
entry_index: 0,
superseded: false,
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,
shuffle: 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 {
generated: Default::default(),
entries: vec![EntryOutcome {
entry_index: 0,
superseded: false,
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,
shuffle: 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
);
}
}