use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use crate::bind::BoundScenario;
use crate::diag::{Diag, Severity};
use crate::feature::FeatureFile;
use crate::pack::{Macro, MacroBody, MacroStep, MacroStepKind, PackSet, PayloadForm};
use crate::resolve::{self, ResolveCtx, ResolveMode};
use crate::step::{Guard, LoweredStep, StepBatch, StepKindId, StepPayload, StepRef};
use crate::world::World;
#[derive(Debug, Clone, Copy)]
pub struct LowerCtx<'a> {
pub feature: &'a FeatureFile,
pub packs: &'a PackSet,
pub kind_to_engine: &'a BTreeMap<String, String>,
pub env: &'a BTreeMap<String, String>,
pub config_vars: &'a BTreeMap<String, String>,
pub run_id: &'a str,
pub world: &'a World,
pub mode: ResolveMode,
}
#[derive(Debug)]
pub struct LoweredScenario {
pub name: String,
pub tags: Vec<String>,
pub line: usize,
pub batches: Vec<StepBatch>,
pub secrets: BTreeMap<String, String>,
pub globals: BTreeSet<String>,
pub warnings: Vec<Diag>,
}
const MAX_EXPANSION_DEPTH: usize = 32;
type Bindings = BTreeMap<String, Bound>;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Bound {
Value(String),
Secret(String),
}
fn whole_secret(value: &str) -> Option<&str> {
let trimmed = value.trim();
let (name, start, end) = crate::resolve::first_reference(trimmed)?;
if start != 0 || end != trimmed.len() {
return None; }
let inner = name.strip_prefix("secret:")?.trim();
(!inner.is_empty() && !inner.contains(['{', '$'])).then_some(inner)
}
fn mentions_secret(value: &str) -> bool {
let mut rest = value;
while let Some((name, _, end)) = crate::resolve::first_reference(rest) {
if name.starts_with("secret:") {
return true;
}
rest = &rest[end..];
}
false
}
fn quote_option(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
pub(crate) fn macro_has_ref(macro_: &Macro) -> bool {
match ¯o_.body {
MacroBody::Steps(steps) => steps
.iter()
.any(|step| matches!(step.kind, MacroStepKind::Ref { .. })),
MacroBody::Expect(_) => false,
}
}
fn scope_bindings(
macro_: &Macro,
ctx: &LowerCtx<'_>,
refs: &mut Refs,
sinks: &mut Sinks,
resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
at: &impl Fn(Diag) -> Diag,
) -> Bindings {
let mut scoped = Bindings::new();
if !macro_has_ref(macro_) {
return scoped;
}
if let Some(table) = ctx.packs.bind.get(¯o_.pack) {
if !refs.pack_bindings.contains_key(¯o_.pack) {
let resolved = resolve_bindings(table, refs, sinks, resolve_in, at);
refs.pack_bindings.insert(macro_.pack.clone(), resolved);
}
if let Some(cached) = refs.pack_bindings.get(¯o_.pack) {
scoped.extend(cached.clone());
}
}
scoped.extend(resolve_bindings(¯o_.bind, refs, sinks, resolve_in, at));
scoped
}
fn resolve_bindings(
table: &BTreeMap<String, String>,
refs: &mut Refs,
sinks: &mut Sinks,
resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
at: &impl Fn(Diag) -> Diag,
) -> Bindings {
let mut out = Bindings::new();
for (name, value) in table {
if let Some(secret) = whole_secret(value) {
out.insert(name.clone(), Bound::Secret(secret.to_owned()));
continue;
}
if mentions_secret(value) {
sinks.errors.push(
at(Diag::error(
"proef::lower::secret_in_composite_bind",
format!(
"binding `{name}` mixes a secret into a larger value — the result would have to be written into the artifact to be injected"
),
))
.with_help(
"bind the secret on its own and put the surrounding text in the fragment \
(`Authorization: Bearer {{token}}` with `bind: { token: ${secret:…} }`)",
),
);
continue;
}
if let Some(resolved) = resolve_in(value, refs, sinks) {
if resolved.contains('\n') {
sinks.errors.push(
at(Diag::error(
"proef::lower::multiline_bind",
format!(
"binding `{name}` resolves to a multi-line value, which a hurl \
`[Options] variable:` cannot carry — it is a single-line scalar"
),
))
.with_help(
"a multi-line body is what the inline form is for: use `hurl: |` and \
splice it with `${…}` (a `${docstring}` body is the usual case)",
),
);
continue;
}
out.insert(name.clone(), Bound::Value(resolved));
}
}
out
}
fn captures_before(out: &[LoweredStep]) -> BTreeSet<String> {
let mut names = BTreeSet::new();
for step in out {
if let StepPayload::HurlEntries(text) = &step.payload {
let lines: Vec<&str> = text.lines().collect();
names.extend(crate::emit::capture_names(&lines));
}
}
names
}
#[derive(Debug, Default)]
struct Sinks {
warnings: Vec<Diag>,
errors: Vec<Diag>,
}
#[derive(Debug, Default)]
struct Refs {
secrets: BTreeMap<String, String>,
globals: BTreeSet<String>,
pack_bindings: BTreeMap<String, Bindings>,
fakes: usize,
}
pub fn lower(scenario: &BoundScenario, ctx: &LowerCtx<'_>) -> Result<LoweredScenario, Vec<Diag>> {
let mut sinks = Sinks::default();
let mut refs = Refs::default();
let mut lowered: Vec<LoweredStep> = Vec::new();
for step in &scenario.steps {
let step_ref = StepRef {
file: Arc::from(ctx.feature.path.as_str()),
line: step.defn.line,
text: Arc::from(step.defn.text.as_str()),
};
let at = |diag: Diag| {
diag.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source))
.with_span(step.defn.span)
};
let Some(macro_) = ctx.packs.macros.get(&step.macro_name) else {
continue; };
expand_macro(
macro_,
&step.args,
&step_ref,
ctx,
0,
&mut lowered,
&mut refs,
&mut sinks,
&at,
);
}
for step in &lowered {
if matches!(step.payload, StepPayload::MergedAsserts { .. }) {
continue; }
if !ctx.kind_to_engine.contains_key(step.kind.as_str()) {
sinks.errors.push(
Diag::error(
"proef::lower::kind_unrouted",
format!(
"internal: step kind `{}` is not claimed by any registered engine \
(registry/pack-validation drift)",
step.kind.as_str()
),
)
.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
);
}
}
if sinks.errors.iter().any(|d| d.severity == Severity::Error) {
return Err(sinks.errors);
}
Ok(LoweredScenario {
name: scenario.name.clone(),
tags: scenario.tags.clone(),
line: scenario.line,
batches: segment(lowered, ctx.kind_to_engine),
secrets: refs.secrets,
globals: refs.globals,
warnings: sinks.warnings,
})
}
#[allow(clippy::too_many_arguments)]
fn expand_macro(
macro_: &Macro,
args: &BTreeMap<String, String>,
step_ref: &StepRef,
ctx: &LowerCtx<'_>,
depth: usize,
out: &mut Vec<LoweredStep>,
refs: &mut Refs,
sinks: &mut Sinks,
at: &impl Fn(Diag) -> Diag,
) {
if depth > MAX_EXPANSION_DEPTH {
sinks.errors.push(at(Diag::error(
"proef::lower::expansion_too_deep",
format!(
"macro expansion exceeded depth {MAX_EXPANSION_DEPTH} at `{}`",
macro_.name
),
)));
return;
}
let resolve_in = |text: &str, refs: &mut Refs, sinks: &mut Sinks| -> Option<String> {
let resolve_ctx = ResolveCtx {
args,
defaults: ¯o_.defaults,
env: ctx.env,
config_vars: ctx.config_vars,
run_id: ctx.run_id,
world: ctx.world,
mode: ctx.mode,
};
match resolve::resolve(text, &resolve_ctx, &mut refs.fakes) {
Ok(resolution) => {
refs.secrets
.extend(resolution.secrets.into_iter().map(|s| (s.clone(), s)));
refs.globals.extend(resolution.globals);
push_warnings(sinks, &resolution.warnings, ctx, ¯o_.name);
Some(resolution.text)
}
Err(err) => {
sinks.errors.push(at(Diag::error(
err.code(),
format!("in macro `{}`: {err}", macro_.name),
)));
None
}
}
};
let scoped = scope_bindings(macro_, ctx, refs, sinks, &resolve_in, at);
match ¯o_.body {
MacroBody::Expect(items) => {
let mut merged: Option<(StepKindId, bool, usize)> = None;
for item in items {
let status = match &item.status {
Some(status) => match resolve_in(status, refs, sinks) {
Some(status) => Some(status),
None => continue,
},
None => None,
};
let fragment = match &item.fragment {
Some(fragment) => match resolve_in(fragment, refs, sinks) {
Some(fragment) => Some(fragment),
None => continue,
},
None => None,
};
if let Some((kind, optional, lines)) =
merge_expect(status.as_deref(), fragment.as_deref(), out, sinks, at)
{
let entry = merged.get_or_insert((kind, optional, 0));
entry.2 += lines;
}
}
if let Some((kind, optional, lines)) = merged {
out.push(LoweredStep {
step: step_ref.clone(),
kind,
payload: StepPayload::MergedAsserts { lines },
optional,
when: None,
label: None,
fragment: None,
save_as: std::collections::BTreeMap::new(),
});
}
}
MacroBody::Steps(steps) => {
for macro_step in steps {
expand_step(
macro_step,
step_ref,
ctx,
depth,
out,
refs,
sinks,
at,
&resolve_in,
&scoped,
);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn expand_step(
macro_step: &MacroStep,
step_ref: &StepRef,
ctx: &LowerCtx<'_>,
depth: usize,
out: &mut Vec<LoweredStep>,
refs: &mut Refs,
sinks: &mut Sinks,
at: &impl Fn(Diag) -> Diag,
resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
scoped: &Bindings,
) {
match ¯o_step.kind {
MacroStepKind::Ref { target } => expand_ref_step(
target, macro_step, step_ref, ctx, out, refs, sinks, at, resolve_in, scoped,
),
MacroStepKind::Use { target, with } => {
let Some(target_macro) = ctx.packs.find_use_target(target) else {
return; };
let mut child_args = BTreeMap::new();
for (key, value) in with {
if let Some(resolved) = resolve_in(value, refs, sinks) {
child_args.insert(key.clone(), resolved);
}
}
expand_macro(
target_macro,
&child_args,
step_ref,
ctx,
depth + 1,
out,
refs,
sinks,
at,
);
}
MacroStepKind::Payload { kind, payload } => expand_payload_step(
macro_step, kind, payload, step_ref, out, refs, sinks, resolve_in,
),
}
}
#[allow(clippy::too_many_arguments)]
fn expand_ref_step(
target: &str,
macro_step: &MacroStep,
step_ref: &StepRef,
ctx: &LowerCtx<'_>,
out: &mut Vec<LoweredStep>,
refs: &mut Refs,
sinks: &mut Sinks,
at: &impl Fn(Diag) -> Diag,
resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
scoped: &Bindings,
) {
let Some(fragment) = ctx.packs.find_fragment(target) else {
return; };
let label_fakes_start = refs.fakes;
let mut bindings = scoped.clone();
bindings.extend(resolve_bindings(
¯o_step.bind,
refs,
sinks,
resolve_in,
at,
));
let unbound: Vec<&str> = fragment
.placeholders
.iter()
.filter(|name| {
!bindings.contains_key(name.as_str())
&& !refs.secrets.contains_key(name.as_str())
&& !fragment.supplied_variables.contains(name)
})
.map(String::as_str)
.collect();
let missing: Vec<&str> = if unbound.is_empty() {
Vec::new()
} else {
let available = captures_before(out);
unbound
.into_iter()
.filter(|name| !available.contains(*name))
.collect()
};
if !missing.is_empty() {
let message = format!(
"fragment `{}` reads `{}`, which nothing supplies — no `bind:` in scope gives a value, no earlier step captures it, and the fragment sets no `[Options] variable:` of its own",
fragment.name,
missing.join("`, `"),
);
sinks.errors.push(
Diag::error("proef::lower::unbound_placeholder", message)
.with_source(fragment.file.clone(), Arc::clone(&fragment.source))
.maybe_span(crate::pack::locate::line_span(
&fragment.source,
fragment.line,
))
.with_help(format!(
"add `bind: {{ {}: … }}` to the step, its macro, or the pack — or give \
the fragment its own `[Options]` `variable: {}=…`, which also keeps the \
file runnable under stock `hurl`",
missing[0], missing[0]
)),
);
return;
}
let mut literals: BTreeMap<String, String> = BTreeMap::new();
for (name, bound) in bindings {
match bound {
Bound::Secret(secret) => {
refs.secrets.insert(name, secret);
}
Bound::Value(value) => {
literals.insert(name, value);
}
}
}
let text = bake_entry_options(
&fragment.text,
macro_step.retry,
macro_step.delay_ms,
&literals,
);
finish_step(
macro_step,
step_ref,
StepKindId::from(fragment.kind.as_str()),
StepPayload::HurlEntries(text),
Some(fragment.qualified()),
label_fakes_start,
out,
refs,
sinks,
resolve_in,
);
}
#[allow(clippy::too_many_arguments)]
fn expand_payload_step(
macro_step: &MacroStep,
kind: &str,
payload: &PayloadForm,
step_ref: &StepRef,
out: &mut Vec<LoweredStep>,
refs: &mut Refs,
sinks: &mut Sinks,
resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
) {
let label_fakes_start = refs.fakes;
let payload = match payload {
PayloadForm::Raw(text) => {
let Some(resolved) = resolve_in(text, refs, sinks) else {
return;
};
let resolved = if macro_step.retry.is_some() || macro_step.delay_ms.is_some() {
bake_entry_options(
&resolved,
macro_step.retry,
macro_step.delay_ms,
&BTreeMap::new(),
)
} else {
resolved
};
StepPayload::HurlEntries(resolved)
}
PayloadForm::Structured(value) => {
let mut resolve = |text: &str| {
if !text.contains('$') {
return Some(text.to_owned());
}
resolve_in(text, refs, sinks)
};
match resolve_structured(value, &mut resolve) {
Some(resolved) => StepPayload::Structured(resolved),
None => return,
}
}
};
finish_step(
macro_step,
step_ref,
StepKindId::from(kind),
payload,
None, label_fakes_start,
out,
refs,
sinks,
resolve_in,
);
}
#[allow(clippy::too_many_arguments)]
fn finish_step(
macro_step: &MacroStep,
step_ref: &StepRef,
kind: StepKindId,
payload: StepPayload,
fragment: Option<String>,
label_fakes_start: usize,
out: &mut Vec<LoweredStep>,
refs: &mut Refs,
sinks: &mut Sinks,
resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
) {
let when = match ¯o_step.when {
Some(guard) => match resolve_in(guard, refs, sinks) {
Some(resolved) => Some(Guard(resolved)),
None => return,
},
None => None,
};
let functional_fakes_end = refs.fakes;
let label = match ¯o_step.name {
Some(name) => {
refs.fakes = label_fakes_start;
let resolved = resolve_in(name, refs, sinks);
refs.fakes = functional_fakes_end.max(refs.fakes);
match resolved {
Some(resolved) => Some(resolved),
None => return,
}
}
None => None,
};
out.push(LoweredStep {
step: step_ref.clone(),
kind,
payload,
optional: macro_step.optional,
when,
label,
fragment,
save_as: macro_step.save_as.clone(),
});
}
fn resolve_structured(
value: &serde_json::Value,
resolve: &mut dyn FnMut(&str) -> Option<String>,
) -> Option<serde_json::Value> {
use serde_json::Value as J;
Some(match value {
J::String(text) => J::String(resolve(text)?),
J::Array(items) => J::Array(
items
.iter()
.map(|item| resolve_structured(item, resolve))
.collect::<Option<_>>()?,
),
J::Object(map) => {
let mut out = serde_json::Map::new();
for (key, item) in map {
out.insert(key.clone(), resolve_structured(item, resolve)?);
}
J::Object(out)
}
other => other.clone(),
})
}
fn bake_entry_options(
text: &str,
retry: Option<crate::step::Retry>,
delay_ms: Option<u64>,
bindings: &BTreeMap<String, String>,
) -> String {
let mut option_lines: Vec<String> = Vec::new();
if let Some(retry) = retry {
option_lines.push(format!("retry: {}", retry.count));
option_lines.push(format!("retry-interval: {}ms", retry.interval_ms));
}
if let Some(delay_ms) = delay_ms {
option_lines.push(format!("delay: {delay_ms}ms"));
}
for (name, value) in bindings {
option_lines.push(format!("variable: {name}=\"{}\"", quote_option(value)));
}
if option_lines.is_empty() {
return text.to_owned();
}
let retry_lines = option_lines.join("\n");
let mut author_options = vec![false];
let mut in_fence = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
if is_method_line(trimmed) {
author_options.push(false);
} else if trimmed == "[Options]"
&& let Some(last) = author_options.last_mut()
{
*last = true;
}
}
let has_author_options = |entry: usize| author_options.get(entry).copied().unwrap_or(false);
let mut out: Vec<String> = Vec::new();
let mut in_entry_head = false; let mut injected_current = false;
let mut in_fence = false; let mut entry = 0usize;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
if !in_fence && in_entry_head && !injected_current && !has_author_options(entry) {
out.push("[Options]".to_owned());
out.push(retry_lines.clone());
injected_current = true;
}
in_fence = !in_fence;
in_entry_head = false;
out.push(line.to_owned());
continue;
}
if in_fence {
out.push(line.to_owned());
continue;
}
if is_method_line(trimmed) {
in_entry_head = true;
injected_current = false;
entry += 1;
out.push(line.to_owned());
continue;
}
if trimmed == "[Options]" {
out.push(line.to_owned());
if !injected_current {
out.push(retry_lines.clone());
injected_current = true;
}
in_entry_head = false;
continue;
}
let is_header = in_entry_head && is_header_line(trimmed);
if in_entry_head && !is_header && !injected_current && !has_author_options(entry) {
out.push("[Options]".to_owned());
out.push(retry_lines.clone());
injected_current = true;
in_entry_head = false;
}
out.push(line.to_owned());
}
if in_entry_head && !injected_current {
out.push("[Options]".to_owned());
out.push(retry_lines.clone());
}
let mut result = out.join("\n");
if text.ends_with('\n') {
result.push('\n');
}
result
}
fn merge_expect(
status: Option<&str>,
fragment: Option<&str>,
out: &mut [LoweredStep],
sinks: &mut Sinks,
at: &impl Fn(Diag) -> Diag,
) -> Option<(StepKindId, bool, usize)> {
let Some(previous) = out
.iter_mut()
.rev()
.find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
else {
sinks.errors.push(
at(Diag::error(
"proef::lower::then_before_when",
"this assert-only step has no previous request entry to attach to",
))
.with_help("a Then step asserts on the request made by an earlier When step"),
);
return None;
};
if let Some(status) = status
&& (!status.chars().all(|c| c.is_ascii_digit()) || status.is_empty())
{
sinks.errors.push(at(Diag::error(
"proef::lower::bad_status",
format!("expected an HTTP status number, got `{status}`"),
)));
return None;
}
let host_kind = previous.kind.clone();
let host_optional = previous.optional;
let StepPayload::HurlEntries(text) = &mut previous.payload else {
return None;
};
let (tail_has_http, tail_has_asserts) = last_entry_scan(text);
if !tail_has_http {
push_line(text, "HTTP *");
}
if !tail_has_asserts {
push_line(text, "[Asserts]");
}
let mut appended = 0usize;
if let Some(status) = status {
push_line(text, &format!("status == {status}"));
appended += 1;
}
if let Some(fragment) = fragment {
for line in fragment.lines().filter(|l| !l.trim().is_empty()) {
push_line(text, line.trim_end());
appended += 1;
}
}
Some((host_kind, host_optional, appended))
}
fn is_header_line(trimmed: &str) -> bool {
let Some((name, _)) = trimmed.split_once(':') else {
return false;
};
!name.is_empty()
&& name != "HTTP"
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
}
pub(crate) fn is_method_line(trimmed: &str) -> bool {
trimmed.split_whitespace().next().is_some_and(|word| {
word.len() >= 3
&& word.chars().all(|c| c.is_ascii_uppercase() || c == '-')
&& word != "HTTP"
}) && trimmed.split_whitespace().count() >= 2
}
fn last_entry_scan(text: &str) -> (bool, bool) {
let mut in_fence = false;
let (mut has_http, mut has_asserts) = (false, false);
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
if is_method_line(trimmed) {
(has_http, has_asserts) = (false, false);
continue;
}
has_http = has_http || trimmed.starts_with("HTTP");
has_asserts = has_asserts || trimmed == "[Asserts]";
}
(has_http, has_asserts)
}
fn push_line(text: &mut String, line: &str) {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(line);
text.push('\n');
}
fn segment(steps: Vec<LoweredStep>, kind_to_engine: &BTreeMap<String, String>) -> Vec<StepBatch> {
let mut batches: Vec<StepBatch> = Vec::new();
for step in steps {
let engine = kind_to_engine
.get(step.kind.as_str())
.map_or_else(|| step.kind.as_str().to_owned(), Clone::clone);
let glued = matches!(step.payload, StepPayload::MergedAsserts { .. });
let start_new = match batches.last() {
None => true,
Some(last) => {
!glued
&& (last.engine.as_str() != engine
|| step.optional
|| last.steps.last().is_some_and(|s| s.optional))
}
};
if start_new {
batches.push(StepBatch {
index: batches.len(),
engine: crate::engine::EngineId::from(engine.as_str()),
steps: vec![step],
});
} else if let Some(last) = batches.last_mut() {
last.steps.push(step);
}
}
batches
}
fn push_warnings(sinks: &mut Sinks, texts: &[String], ctx: &LowerCtx<'_>, where_: &str) {
for text in texts {
sinks.warnings.push(
Diag::warning("proef::lower::dry_run_unknown", format!("{where_}: {text}"))
.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::engine::StepKindSpec;
use crate::pack::{self, PackSource};
use crate::step::StepPayload;
const KINDS: &[StepKindSpec] = &[StepKindSpec {
prefix: "hurl",
schema: "true",
validate: None,
fragments: None,
options: None,
}];
const PACK: &str = r#"macros:
auth:
params: [token]
steps:
- name: authenticate
hurl: |
POST ${url:base}/auth
Authorization: Bearer ${token}
HTTP 200
search:
params: [term]
match: "I search for {term}"
steps:
- use: auth
with: { token: "${secret:apiToken}" }
- name: run the search
hurl: |
GET ${url:base}/search?q=${term}
HTTP 200
[Captures]
recordId: jsonpath "$[0].id"
checkHealth:
match: the service is healthy
steps:
- optional: true
hurl: |
GET ${url:base}/health
expectStatus:
params: [status]
match: "the response status is {status}"
expect:
- status: "${status}"
"#;
fn fixture() -> (
crate::feature::FeatureFile,
crate::bind::BoundScenario,
PackSet,
) {
let packs = pack::load(
&[PackSource {
name: "test.yaml".into(),
text: Arc::from(PACK),
}],
&crate::pack::FragmentCorpus::empty(),
KINDS,
)
.unwrap();
let feature = crate::feature::parse(
"t.feature",
"Feature: F\n Scenario: S\n Given the service is healthy\n When I search for \"Jansen\"\n Then the response status is 200\n",
)
.unwrap();
let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
(feature, scenario, packs)
}
fn ctx<'a>(
feature: &'a crate::feature::FeatureFile,
packs: &'a PackSet,
kind_to_engine: &'a BTreeMap<String, String>,
env: &'a BTreeMap<String, String>,
config_vars: &'a BTreeMap<String, String>,
world: &'a World,
) -> LowerCtx<'a> {
LowerCtx {
feature,
packs,
kind_to_engine,
env,
config_vars,
run_id: "run-0001",
world,
mode: ResolveMode::DryRun,
}
}
#[test]
fn expansion_resolution_merge_and_segmentation_work_together() {
let (feature, scenario, packs) = fixture();
let kind_to_engine: BTreeMap<String, String> =
[("hurl".to_owned(), "hurl".to_owned())].into();
let env = BTreeMap::new();
let config_vars =
BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
let world = World::default();
let lowered = lower(
&scenario,
&ctx(
&feature,
&packs,
&kind_to_engine,
&env,
&config_vars,
&world,
),
)
.unwrap();
assert_eq!(lowered.batches.len(), 2);
assert_eq!(lowered.batches[0].steps.len(), 1);
assert!(lowered.batches[0].steps[0].optional);
assert_eq!(lowered.batches[1].steps.len(), 3);
let StepPayload::MergedAsserts { lines } = lowered.batches[1].steps[2].payload else {
panic!("expected a merged-asserts step for the Then line");
};
assert_eq!(lines, 1, "the expect appended exactly `status == 200`");
let StepPayload::HurlEntries(auth) = &lowered.batches[1].steps[0].payload else {
panic!("expected hurl entries");
};
assert!(auth.contains("POST http://fixture.local/auth"), "{auth}");
assert!(
auth.contains("Bearer {{apiToken}}"),
"secret placeholder: {auth}"
);
assert!(lowered.secrets.contains_key("apiToken"));
let StepPayload::HurlEntries(search) = &lowered.batches[1].steps[1].payload else {
panic!("expected hurl entries");
};
assert!(
search.contains("GET http://fixture.local/search?q=Jansen"),
"{search}"
);
assert!(search.contains("[Asserts]"), "{search}");
assert!(search.trim_end().ends_with("status == 200"), "{search}");
assert_eq!(lowered.batches[1].steps[1].step.line, 4);
assert_eq!(
lowered.batches[1].steps[0].label.as_deref(),
Some("authenticate")
);
}
#[test]
fn then_before_when_is_an_error() {
let (_, _, packs) = fixture();
let feature = crate::feature::parse(
"t.feature",
"Feature: F\n Scenario: S\n Then the response status is 200\n",
)
.unwrap();
let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
let kind_to_engine = BTreeMap::new();
let env = BTreeMap::new();
let config_vars = BTreeMap::new();
let world = World::default();
let errs = lower(
&scenario,
&ctx(
&feature,
&packs,
&kind_to_engine,
&env,
&config_vars,
&world,
),
)
.unwrap_err();
assert_eq!(errs[0].code, "proef::lower::then_before_when");
}
#[test]
fn an_expect_fragment_that_resolves_empty_does_not_invert_the_merged_span() {
const PACK: &str = r#"macros:
ping:
match: the service is pinged
steps:
- hurl: |
GET ${url:base}/ping
HTTP 200
expectBlank:
match: nothing extra is asserted
expect:
- hurl: "${vars:blank}"
"#;
let packs = pack::load(
&[PackSource {
name: "test.yaml".into(),
text: Arc::from(PACK),
}],
&crate::pack::FragmentCorpus::empty(),
KINDS,
)
.unwrap();
let feature = crate::feature::parse(
"t.feature",
"Feature: F\n Scenario: S\n Given the service is pinged\n Then nothing extra is asserted\n",
)
.unwrap();
let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
let kind_to_engine: BTreeMap<String, String> =
[("hurl".to_owned(), "hurl".to_owned())].into();
let env = BTreeMap::new();
let config_vars = BTreeMap::from([
("url:base".to_owned(), "http://fixture.local".to_owned()),
("vars:blank".to_owned(), String::new()),
]);
let world = World::default();
let lowered = lower(
&scenario,
&ctx(
&feature,
&packs,
&kind_to_engine,
&env,
&config_vars,
&world,
),
)
.unwrap();
assert_eq!(lowered.batches[0].steps.len(), 2);
let StepPayload::MergedAsserts { lines } = lowered.batches[0].steps[1].payload else {
panic!("expected a merged-asserts step for the Then line");
};
assert_eq!(lines, 0, "the fragment resolved to nothing");
let artifact = crate::emit::emit(&lowered, "t", &world).unwrap();
for entry in &artifact.map.entries {
let [start, end] = entry.hurl_lines;
assert!(
start <= end,
"inverted span for a zero-line merge: {start}..{end}"
);
}
}
#[test]
fn structured_payloads_resolve_placeholders_recursively() {
const ALT_KINDS: &[StepKindSpec] = &[StepKindSpec {
prefix: "alt",
schema: "true",
validate: None,
fragments: None,
options: None,
}];
let packs = pack::load(
&[PackSource {
name: "alt.yaml".into(),
text: Arc::from(
"macros:\n probe:\n match: the alternate step runs\n steps:\n - name: probe\n alt:\n target: \"${url:base}/item\"\n checks: [\"${url:base}\", 7]\n",
),
}],
&crate::pack::FragmentCorpus::empty(),
ALT_KINDS,
)
.unwrap();
let feature = crate::feature::parse(
"t.feature",
"Feature: F\n Scenario: S\n When the alternate step runs\n",
)
.unwrap();
let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
let kind_to_engine: BTreeMap<String, String> =
[("alt".to_owned(), "alt".to_owned())].into();
let env = BTreeMap::new();
let config_vars =
BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
let world = World::default();
let lowered = lower(
&scenario,
&ctx(
&feature,
&packs,
&kind_to_engine,
&env,
&config_vars,
&world,
),
)
.unwrap();
let StepPayload::Structured(value) = &lowered.batches[0].steps[0].payload else {
panic!("structured payload expected");
};
assert_eq!(value["target"], "http://fixture.local/item");
assert_eq!(value["checks"][0], "http://fixture.local");
assert_eq!(value["checks"][1], 7);
}
#[test]
fn expect_merge_scopes_to_the_last_entry() {
let packs = pack::load(
&[PackSource {
name: "multi.yaml".into(),
text: Arc::from(
"macros:\n pair:\n match: both calls run\n steps:\n - hurl: |\n GET http://x/a\n HTTP 200\n [Asserts]\n status == 200\n GET http://x/b\n expectStatus:\n params: [status]\n match: \"the response status is {status}\"\n expect:\n - status: \"${status}\"\n",
),
}],
&crate::pack::FragmentCorpus::empty(),
KINDS,
)
.unwrap();
let feature = crate::feature::parse(
"t.feature",
"Feature: F\n Scenario: S\n When both calls run\n Then the response status is 201\n",
)
.unwrap();
let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
let kind_to_engine: BTreeMap<String, String> =
[("hurl".to_owned(), "hurl".to_owned())].into();
let env = BTreeMap::new();
let config_vars = BTreeMap::new();
let world = World::default();
let lowered = lower(
&scenario,
&ctx(
&feature,
&packs,
&kind_to_engine,
&env,
&config_vars,
&world,
),
)
.unwrap();
let StepPayload::HurlEntries(text) = &lowered.batches[0].steps[0].payload else {
panic!("expected hurl entries");
};
let tail = text.split("GET http://x/b").nth(1).unwrap();
assert!(tail.contains("HTTP *"), "{text}");
assert!(tail.contains("[Asserts]"), "{text}");
assert!(tail.contains("status == 201"), "{text}");
}
#[test]
fn baked_options_extend_a_late_author_options_section() {
let retry = Some(crate::step::Retry {
count: 2,
interval_ms: 100,
});
let body =
"GET http://x/a\n[QueryStringParams]\nq: 1\n[Options]\nverbose: true\nHTTP 200\n";
let baked = bake_entry_options(body, retry, None, &BTreeMap::new());
assert_eq!(baked.matches("[Options]").count(), 1, "{baked}");
assert!(
baked.contains("[Options]\nretry: 2\nretry-interval: 100ms\nverbose: true"),
"{baked}"
);
}
#[test]
fn baked_options_never_enter_bodies() {
let retry = Some(crate::step::Retry {
count: 2,
interval_ms: 100,
});
for body in [
"POST http://x/a\n```\nNOTE FOR REVIEW\nsecond line\n```\nHTTP 200\n",
"POST http://x/a\n<root xmlns:x=\"urn:example\">\n <child>hi</child>\n</root>\nHTTP 200\n",
"POST http://x/a\n{\"note\": \"FOR REVIEW\"}\nHTTP 200\n",
] {
let baked = bake_entry_options(body, retry, None, &BTreeMap::new());
assert_eq!(
baked.matches("[Options]").count(),
1,
"exactly one options block in:\n{baked}"
);
let options_at = baked.find("[Options]").unwrap_or(usize::MAX);
let body_at = baked
.find("```")
.or_else(|| baked.find('<'))
.or_else(|| baked.find('{'))
.unwrap_or(0);
assert!(options_at < body_at, "options precede the body:\n{baked}");
}
}
#[test]
fn engine_change_splits_batches() {
let steps: Vec<LoweredStep> = ["hurl", "hurl", "alt", "hurl"]
.iter()
.map(|kind| LoweredStep {
step: StepRef {
file: Arc::from("f"),
line: 1,
text: Arc::from("t"),
},
kind: StepKindId::from(*kind),
payload: StepPayload::HurlEntries(String::new()),
optional: false,
when: None,
label: None,
fragment: None,
save_as: BTreeMap::new(),
})
.collect();
let mapping: BTreeMap<String, String> = [
("hurl".to_owned(), "hurl".to_owned()),
("alt".to_owned(), "alt".to_owned()),
]
.into();
let batches = segment(steps, &mapping);
let sizes: Vec<usize> = batches.iter().map(|b| b.steps.len()).collect();
assert_eq!(sizes, vec![2, 1, 1]);
assert_eq!(batches[1].engine.as_str(), "alt");
let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
assert_eq!(indexes, vec![0, 1, 2]);
}
#[test]
fn label_mirrors_the_payloads_fake_values_without_shifting_later_steps() {
const FAKE_PACK: &str = r#"macros:
searchFor:
params: [term]
match: "the operator searches for {term}"
steps:
- name: "search for ${term}"
hurl: |
GET ${url:base}/search
[Query]
q: ${term}
HTTP 200
pingFake:
match: a fresh fake is requested
steps:
- hurl: |
GET ${url:base}/ping
[Query]
v: ${fake:lastName}
HTTP 200
"#;
let packs = pack::load(
&[PackSource {
name: "fakes.yaml".into(),
text: Arc::from(FAKE_PACK),
}],
&crate::pack::FragmentCorpus::empty(),
KINDS,
)
.unwrap();
let feature = crate::feature::parse(
"t.feature",
"Feature: F\n Scenario: S\n When the operator searches for ${fake:lastName}\n Then a fresh fake is requested\n",
)
.unwrap();
let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
let kind_to_engine: BTreeMap<String, String> =
[("hurl".to_owned(), "hurl".to_owned())].into();
let env = BTreeMap::new();
let config_vars =
BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
let world = World::default();
let lowered = lower(
&scenario,
&ctx(
&feature,
&packs,
&kind_to_engine,
&env,
&config_vars,
&world,
),
)
.unwrap();
assert_eq!(lowered.batches[0].steps.len(), 2);
let StepPayload::HurlEntries(search) = &lowered.batches[0].steps[0].payload else {
panic!("expected hurl entries");
};
let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
assert!(
search.contains(&format!("q: {occurrence_0}")),
"payload: {search}"
);
assert!(label.contains(&occurrence_0), "label: {label}");
let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
panic!("expected hurl entries");
};
let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
assert!(ping.contains(&format!("v: {occurrence_1}")), "ping: {ping}");
}
#[test]
fn label_with_more_fakes_than_its_payload_does_not_leak_occurrences_to_later_steps() {
const FAKE_PACK: &str = r#"macros:
unmirroredLabel:
match: a label mentions more fakes than its payload
steps:
- name: "${fake:lastName} vs ${fake:lastName}"
hurl: |
GET ${url:base}/probe
[Query]
q: ${fake:lastName}
HTTP 200
pingFake:
match: a fresh fake is requested
steps:
- hurl: |
GET ${url:base}/ping
[Query]
v: ${fake:lastName}
HTTP 200
"#;
let packs = pack::load(
&[PackSource {
name: "unmirrored.yaml".into(),
text: Arc::from(FAKE_PACK),
}],
&crate::pack::FragmentCorpus::empty(),
KINDS,
)
.unwrap();
let feature = crate::feature::parse(
"t.feature",
"Feature: F\n Scenario: S\n When a label mentions more fakes than its payload\n Then a fresh fake is requested\n",
)
.unwrap();
let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
let kind_to_engine: BTreeMap<String, String> =
[("hurl".to_owned(), "hurl".to_owned())].into();
let env = BTreeMap::new();
let config_vars =
BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
let world = World::default();
let lowered = lower(
&scenario,
&ctx(
&feature,
&packs,
&kind_to_engine,
&env,
&config_vars,
&world,
),
)
.unwrap();
assert_eq!(lowered.batches[0].steps.len(), 2);
let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
panic!("expected hurl entries");
};
let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
assert!(label.contains(&occurrence_0), "label: {label}");
assert!(label.contains(&occurrence_1), "label: {label}");
let occurrence_2 = crate::fake::generate("run-0001", 2, "lastName").unwrap();
assert!(
!ping.contains(&format!("v: {occurrence_1}")),
"the next step's fake reused an occurrence the label already \
displayed: {ping}"
);
assert!(ping.contains(&format!("v: {occurrence_2}")), "ping: {ping}");
}
#[allow(clippy::unnecessary_wraps)]
fn frag_scan(
text: &str,
) -> Result<crate::engine::ScannedFile, crate::engine::FragmentScanError> {
let mut out: Vec<crate::engine::ScannedFragment> = Vec::new();
for (index, line) in text.lines().enumerate() {
let line = line.trim();
if let Some(name) = line.strip_prefix('@') {
out.push(crate::engine::ScannedFragment {
name: name.to_owned(),
text: format!("GET http://x/{name}\nHTTP 200\n"),
line: index + 1,
placeholders: Vec::new(),
declared_options: Vec::new(),
supplied_variables: Vec::new(),
});
} else if let Some(last) = out.last_mut() {
if let Some(read) = line.strip_prefix('?') {
last.placeholders.push(read.to_owned());
} else if let Some(supplied) = line.strip_prefix('=') {
let head = last.text.find("HTTP ").unwrap_or(last.text.len());
let section = if last.text[..head].contains("[Options]") {
format!("variable: {supplied}=from-fragment\n")
} else {
format!("[Options]\nvariable: {supplied}=from-fragment\n")
};
last.text.insert_str(head, §ion);
last.supplied_variables.push(supplied.to_owned());
} else if let Some(write) = line.strip_prefix('!') {
if !last.text.contains("[Captures]") {
last.text.push_str("[Captures]\n");
}
last.text.push_str(write);
last.text.push_str(": jsonpath \"$.id\"\n");
}
}
}
Ok(crate::engine::ScannedFile {
fragments: out,
unannotated: Vec::new(),
})
}
const FRAG_KINDS: &[StepKindSpec] = &[StepKindSpec {
prefix: "hurl",
schema: "true",
validate: None,
fragments: Some(crate::engine::FragmentSupport {
ext: "frag",
scan: frag_scan,
}),
options: None,
}];
fn lower_fragments(pack: &str, fragments: &str) -> Result<LoweredScenario, Vec<Diag>> {
let packs = pack::load(
&[PackSource {
name: "p.yaml".into(),
text: Arc::from(pack),
}],
&pack::FragmentCorpus::new(
vec![PackSource {
name: "api.frag".into(),
text: Arc::from(fragments),
}],
FRAG_KINDS,
),
FRAG_KINDS,
)
.unwrap_or_else(|err| panic!("pack should load: {err:?}"));
let feature =
crate::feature::parse("t.feature", "Feature: F\n Scenario: S\n When it runs\n")
.unwrap();
let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
let kind_to_engine = BTreeMap::from([("hurl".to_owned(), "hurl".to_owned())]);
let env = BTreeMap::new();
let config_vars = BTreeMap::from([("url:base".to_owned(), "http://api".to_owned())]);
let world = World::new(crate::world::GlobalStore::default());
let ctx = ctx(
&feature,
&packs,
&kind_to_engine,
&env,
&config_vars,
&world,
);
lower(&scenario, &ctx)
}
fn only_entry(lowered: &LoweredScenario) -> &str {
let step = lowered
.batches
.iter()
.flat_map(|b| b.steps.iter())
.find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
.expect("one hurl entry");
let StepPayload::HurlEntries(text) = &step.payload else {
unreachable!()
};
text
}
#[test]
fn bindings_cascade_and_are_injected_as_entry_options() {
let lowered = lower_fragments(
"bind:\n base: ${url:base}\n who: pack\nmacros:\n m:\n match: it runs\n bind:\n who: macro\n extra: yes\n steps:\n - ref: f\n bind:\n who: step\n",
"@f\n?base\n?who\n?extra\n",
)
.expect("lowers");
let text = only_entry(&lowered);
assert!(text.contains("[Options]"), "{text}");
assert!(text.contains(r#"variable: base="http://api""#), "{text}");
assert!(
text.contains(r#"variable: who="step""#),
"step scope wins: {text}"
);
assert!(!text.contains(r#"who="macro""#) && !text.contains(r#"who="pack""#));
assert!(text.contains(r#"variable: extra="yes""#), "{text}");
}
#[test]
fn a_secret_binding_is_renamed_not_written() {
let lowered = lower_fragments(
"macros:\n m:\n match: it runs\n bind:\n auth_token: ${secret:apiToken}\n steps:\n - ref: f\n",
"@f\n?auth_token\n",
)
.expect("lowers");
let text = only_entry(&lowered);
assert!(
!text.contains("auth_token=") && !text.contains("apiToken"),
"no secret may reach the artifact: {text}"
);
assert_eq!(
lowered.secrets.get("auth_token").map(String::as_str),
Some("apiToken")
);
}
#[test]
fn a_secret_mixed_into_a_larger_value_is_refused() {
let diags = lower_fragments(
"macros:\n m:\n match: it runs\n bind:\n auth: \"Bearer ${secret:apiToken}\"\n steps:\n - ref: f\n",
"@f\n?auth\n",
)
.expect_err("should refuse");
assert!(
diags
.iter()
.any(|d| d.code == "proef::lower::secret_in_composite_bind"),
"{diags:?}"
);
}
#[test]
fn an_escaped_secret_reference_is_a_literal_not_a_secret() {
let lowered = lower_fragments(
"macros:\n m:\n match: it runs\n bind:\n hint: $${secret:apiToken}\n steps:\n - ref: f\n",
"@f\n?hint\n",
)
.expect("an escaped reference is ordinary text");
assert!(
lowered.secrets.is_empty(),
"nothing was bound to a secret: {:?}",
lowered.secrets
);
assert!(
only_entry(&lowered).contains(r#"variable: hint="${secret:apiToken}""#),
"the literal is injected verbatim: {}",
only_entry(&lowered)
);
}
#[test]
fn a_variable_the_fragment_supplies_itself_needs_no_binding() {
let lowered = lower_fragments(
"macros:\n m:\n match: it runs\n steps:\n - ref: first\n",
"@first\n=token\n?token\n",
)
.expect("a fragment that supplies its own variable lowers");
let text = lowered
.batches
.iter()
.flat_map(|b| b.steps.iter())
.find_map(|s| match &s.payload {
StepPayload::HurlEntries(text) => Some(text.clone()),
_ => None,
})
.expect("hurl entries");
assert_eq!(
text.matches("variable: token=").count(),
1,
"exactly one supplier reaches the entry: {text}"
);
}
#[test]
fn a_multiline_binding_is_refused_by_name() {
let err = lower_fragments(
"macros:\n m:\n match: it runs\n steps:\n - ref: first\n bind:\n body: |\n one\n two\n",
"@first\n?body\n",
)
.expect_err("a multi-line binding cannot be carried");
let diag = err
.iter()
.find(|d| d.code == "proef::lower::multiline_bind")
.unwrap_or_else(|| panic!("expected multiline_bind in {err:?}"));
assert!(diag.message.contains("body"), "{}", diag.message);
}
#[test]
fn a_placeholder_nothing_supplies_is_refused() {
let diags = lower_fragments(
"macros:\n m:\n match: it runs\n steps:\n - ref: f\n",
"@f\n?missingOne\n",
)
.expect_err("should refuse");
let diag = diags
.iter()
.find(|d| d.code == "proef::lower::unbound_placeholder")
.unwrap_or_else(|| panic!("expected unbound_placeholder in {diags:?}"));
assert!(diag.message.contains("missingOne"), "{}", diag.message);
assert!(diag.help.is_some());
}
#[test]
fn one_binding_is_one_value_across_a_macros_steps() {
let lowered = lower_fragments(
"macros:\n m:\n match: it runs\n bind:\n shared: ${fake:email}\n steps:\n - ref: first\n bind:\n own: ${fake:email}\n - ref: second\n bind:\n own: ${fake:email}\n",
"@first\n?shared\n?own\n@second\n?shared\n?own\n",
)
.expect("lowers");
let entries: Vec<&str> = lowered
.batches
.iter()
.flat_map(|b| b.steps.iter())
.filter_map(|s| match &s.payload {
StepPayload::HurlEntries(text) => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(entries.len(), 2);
let shared = |text: &str| {
text.lines()
.find(|l| l.starts_with("variable: shared="))
.expect("shared binding")
.to_owned()
};
let own = |text: &str| {
text.lines()
.find(|l| l.starts_with("variable: own="))
.expect("own binding")
.to_owned()
};
assert_eq!(
shared(entries[0]),
shared(entries[1]),
"one macro-scope binding is one value for the whole macro"
);
assert_ne!(
own(entries[0]),
own(entries[1]),
"two step-scope bindings are two values"
);
}
#[test]
fn a_ref_steps_label_replays_its_binding_instead_of_minting_a_fresh_value() {
let labelled = lower_fragments(
"macros:\n m:\n match: it runs\n steps:\n - ref: first\n name: signup ${fake:email}\n bind:\n who: ${fake:email}\n - ref: second\n bind:\n who: ${fake:email}\n",
"@first\n?who\n@second\n?who\n",
)
.expect("lowers");
let steps: Vec<&LoweredStep> = labelled
.batches
.iter()
.flat_map(|b| b.steps.iter())
.collect();
assert_eq!(steps.len(), 2);
let who = |step: &LoweredStep| {
let StepPayload::HurlEntries(text) = &step.payload else {
unreachable!()
};
text.lines()
.find_map(|l| l.strip_prefix("variable: who="))
.expect("who binding")
.trim_matches('"')
.to_owned()
};
assert_eq!(
steps[0].label.as_deref(),
Some(format!("signup {}", who(steps[0])).as_str()),
"the label must report the value its own binding sent"
);
let control = lower_fragments(
"macros:\n m:\n match: it runs\n steps:\n - ref: first\n bind:\n who: ${fake:email}\n - ref: second\n bind:\n who: ${fake:email}\n",
"@first\n?who\n@second\n?who\n",
)
.expect("lowers");
let control_steps: Vec<&LoweredStep> = control
.batches
.iter()
.flat_map(|b| b.steps.iter())
.collect();
assert_eq!(
who(steps[1]),
who(control_steps[1]),
"a label must not shift a later step's fake values"
);
}
mod properties {
#![allow(clippy::ignored_unit_patterns)]
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn a_renamed_secret_binds_by_name_and_never_by_value(
variable in "[a-z][a-z_]{2,12}",
secret in "[a-zA-Z][a-zA-Z0-9]{3,12}",
literal in "[a-z][a-z0-9]{2,10}",
) {
prop_assume!(variable != "plain");
let pack = format!(
"macros:\n m:\n match: it runs\n bind:\n {variable}: ${{secret:{secret}}}\n plain: {literal}\n steps:\n - ref: f\n"
);
let fragments = format!("@f\n?{variable}\n?plain\n");
let lowered = lower_fragments(&pack, &fragments)
.unwrap_or_else(|d| panic!("should lower: {d:?}"));
let text = only_entry(&lowered);
let variable_line = format!("variable: {variable}=");
prop_assert!(!text.contains(&variable_line));
prop_assert!(!text.contains(&secret));
let plain_line = format!("variable: plain=\"{literal}\"");
prop_assert!(text.contains(&plain_line));
prop_assert_eq!(
lowered.secrets.get(&variable).map(String::as_str),
Some(secret.as_str())
);
}
}
}
#[test]
fn a_capture_from_an_earlier_step_supplies_a_later_fragment() {
lower_fragments(
"macros:\n m:\n match: it runs\n steps:\n - ref: first\n - ref: second\n",
"@first\n!recordId\n@second\n?recordId\n",
)
.expect("a preceding capture supplies it");
}
}