1use std::collections::{BTreeMap, BTreeSet};
13use std::sync::Arc;
14
15use crate::bind::BoundScenario;
16use crate::diag::{Diag, Severity};
17use crate::feature::FeatureFile;
18use crate::pack::{Macro, MacroBody, MacroStep, MacroStepKind, PackSet, PayloadForm};
19use crate::resolve::{self, ResolveCtx, ResolveMode};
20use crate::step::{Guard, LoweredStep, StepBatch, StepKindId, StepPayload, StepRef};
21use crate::world::World;
22
23#[derive(Debug, Clone, Copy)]
25pub struct LowerCtx<'a> {
26 pub feature: &'a FeatureFile,
28 pub packs: &'a PackSet,
30 pub kind_to_engine: &'a BTreeMap<String, String>,
32 pub env: &'a BTreeMap<String, String>,
34 pub config_vars: &'a BTreeMap<String, String>,
37 pub run_id: &'a str,
39 pub world: &'a World,
41 pub mode: ResolveMode,
43}
44
45#[derive(Debug)]
47pub struct LoweredScenario {
48 pub name: String,
50 pub tags: Vec<String>,
52 pub line: usize,
54 pub batches: Vec<StepBatch>,
56 pub secrets: BTreeMap<String, String>,
62 pub globals: BTreeSet<String>,
64 pub warnings: Vec<Diag>,
66}
67
68const MAX_EXPANSION_DEPTH: usize = 32;
70
71type Bindings = BTreeMap<String, Bound>;
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76enum Bound {
77 Value(String),
80 Secret(String),
84}
85
86fn whole_secret(value: &str) -> Option<&str> {
96 let trimmed = value.trim();
97 let (name, start, end) = crate::resolve::first_reference(trimmed)?;
98 if start != 0 || end != trimmed.len() {
99 return None; }
101 let inner = name.strip_prefix("secret:")?.trim();
102 (!inner.is_empty() && !inner.contains(['{', '$'])).then_some(inner)
103}
104
105fn mentions_secret(value: &str) -> bool {
108 let mut rest = value;
109 while let Some((name, _, end)) = crate::resolve::first_reference(rest) {
110 if name.starts_with("secret:") {
111 return true;
112 }
113 rest = &rest[end..];
114 }
115 false
116}
117
118fn quote_option(value: &str) -> String {
122 value.replace('\\', "\\\\").replace('"', "\\\"")
123}
124
125fn macro_has_ref(macro_: &Macro) -> bool {
130 match ¯o_.body {
131 MacroBody::Steps(steps) => steps
132 .iter()
133 .any(|step| matches!(step.kind, MacroStepKind::Ref { .. })),
134 MacroBody::Expect(_) => false,
135 }
136}
137
138fn scope_bindings(
144 macro_: &Macro,
145 ctx: &LowerCtx<'_>,
146 refs: &mut Refs,
147 sinks: &mut Sinks,
148 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
149 at: &impl Fn(Diag) -> Diag,
150) -> Bindings {
151 let mut scoped = Bindings::new();
152 if !macro_has_ref(macro_) {
153 return scoped;
154 }
155 if let Some(table) = ctx.packs.bind.get(¯o_.pack) {
156 if !refs.pack_bindings.contains_key(¯o_.pack) {
157 let resolved = resolve_bindings(table, refs, sinks, resolve_in, at);
158 refs.pack_bindings.insert(macro_.pack.clone(), resolved);
159 }
160 if let Some(cached) = refs.pack_bindings.get(¯o_.pack) {
161 scoped.extend(cached.clone());
162 }
163 }
164 scoped.extend(resolve_bindings(¯o_.bind, refs, sinks, resolve_in, at));
165 scoped
166}
167
168fn resolve_bindings(
170 table: &BTreeMap<String, String>,
171 refs: &mut Refs,
172 sinks: &mut Sinks,
173 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
174 at: &impl Fn(Diag) -> Diag,
175) -> Bindings {
176 let mut out = Bindings::new();
177 for (name, value) in table {
178 if let Some(secret) = whole_secret(value) {
179 out.insert(name.clone(), Bound::Secret(secret.to_owned()));
180 continue;
181 }
182 if mentions_secret(value) {
183 sinks.errors.push(
184 at(Diag::error(
185 "proef::lower::secret_in_composite_bind",
186 format!(
187 "binding `{name}` mixes a secret into a larger value — the result would have to be written into the artifact to be injected"
188 ),
189 ))
190 .with_help(
191 "bind the secret on its own and put the surrounding text in the fragment \
192 (`Authorization: Bearer {{token}}` with `bind: { token: ${secret:…} }`)",
193 ),
194 );
195 continue;
196 }
197 if let Some(resolved) = resolve_in(value, refs, sinks) {
198 out.insert(name.clone(), Bound::Value(resolved));
199 }
200 }
201 out
202}
203
204fn captures_before(out: &[LoweredStep]) -> BTreeSet<String> {
207 let mut names = BTreeSet::new();
208 for step in out {
209 if let StepPayload::HurlEntries(text) = &step.payload {
210 let lines: Vec<&str> = text.lines().collect();
211 names.extend(crate::emit::capture_names(&lines));
212 }
213 }
214 names
215}
216
217#[derive(Debug, Default)]
225struct Sinks {
226 warnings: Vec<Diag>,
228 errors: Vec<Diag>,
230}
231
232#[derive(Debug, Default)]
234struct Refs {
235 secrets: BTreeMap<String, String>,
237 globals: BTreeSet<String>,
238 pack_bindings: BTreeMap<String, Bindings>,
243 fakes: usize,
251}
252
253pub fn lower(scenario: &BoundScenario, ctx: &LowerCtx<'_>) -> Result<LoweredScenario, Vec<Diag>> {
255 let mut sinks = Sinks::default();
256 let mut refs = Refs::default();
257 let mut lowered: Vec<LoweredStep> = Vec::new();
258 for step in &scenario.steps {
259 let step_ref = StepRef {
260 file: Arc::from(ctx.feature.path.as_str()),
261 line: step.defn.line,
262 text: Arc::from(step.defn.text.as_str()),
263 };
264 let at = |diag: Diag| {
265 diag.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source))
266 .with_span(step.defn.span)
267 };
268 let Some(macro_) = ctx.packs.macros.get(&step.macro_name) else {
269 continue; };
271 expand_macro(
272 macro_,
273 &step.args,
274 &step_ref,
275 ctx,
276 0,
277 &mut lowered,
278 &mut refs,
279 &mut sinks,
280 &at,
281 );
282 }
283
284 for step in &lowered {
290 if matches!(step.payload, StepPayload::MergedAsserts { .. }) {
291 continue; }
293 if !ctx.kind_to_engine.contains_key(step.kind.as_str()) {
294 sinks.errors.push(
295 Diag::error(
296 "proef::lower::kind_unrouted",
297 format!(
298 "internal: step kind `{}` is not claimed by any registered engine \
299 (registry/pack-validation drift)",
300 step.kind.as_str()
301 ),
302 )
303 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
304 );
305 }
306 }
307
308 if sinks.errors.iter().any(|d| d.severity == Severity::Error) {
309 return Err(sinks.errors);
310 }
311
312 Ok(LoweredScenario {
313 name: scenario.name.clone(),
314 tags: scenario.tags.clone(),
315 line: scenario.line,
316 batches: segment(lowered, ctx.kind_to_engine),
317 secrets: refs.secrets,
318 globals: refs.globals,
319 warnings: sinks.warnings,
320 })
321}
322
323#[allow(clippy::too_many_arguments)]
325fn expand_macro(
326 macro_: &Macro,
327 args: &BTreeMap<String, String>,
328 step_ref: &StepRef,
329 ctx: &LowerCtx<'_>,
330 depth: usize,
331 out: &mut Vec<LoweredStep>,
332 refs: &mut Refs,
333 sinks: &mut Sinks,
334 at: &impl Fn(Diag) -> Diag,
335) {
336 if depth > MAX_EXPANSION_DEPTH {
337 sinks.errors.push(at(Diag::error(
338 "proef::lower::expansion_too_deep",
339 format!(
340 "macro expansion exceeded depth {MAX_EXPANSION_DEPTH} at `{}`",
341 macro_.name
342 ),
343 )));
344 return;
345 }
346
347 let resolve_in = |text: &str, refs: &mut Refs, sinks: &mut Sinks| -> Option<String> {
348 let resolve_ctx = ResolveCtx {
349 args,
350 defaults: ¯o_.defaults,
351 env: ctx.env,
352 config_vars: ctx.config_vars,
353 run_id: ctx.run_id,
354 world: ctx.world,
355 mode: ctx.mode,
356 };
357 match resolve::resolve(text, &resolve_ctx, &mut refs.fakes) {
358 Ok(resolution) => {
359 refs.secrets
363 .extend(resolution.secrets.into_iter().map(|s| (s.clone(), s)));
364 refs.globals.extend(resolution.globals);
365 push_warnings(sinks, &resolution.warnings, ctx, ¯o_.name);
366 Some(resolution.text)
367 }
368 Err(err) => {
369 sinks.errors.push(at(Diag::error(
370 err.code(),
371 format!("in macro `{}`: {err}", macro_.name),
372 )));
373 None
374 }
375 }
376 };
377
378 let scoped = scope_bindings(macro_, ctx, refs, sinks, &resolve_in, at);
382
383 match ¯o_.body {
384 MacroBody::Expect(items) => {
385 let mut merged: Option<(StepKindId, bool, usize)> = None;
386 for item in items {
387 let status = match &item.status {
388 Some(status) => match resolve_in(status, refs, sinks) {
389 Some(status) => Some(status),
390 None => continue,
391 },
392 None => None,
393 };
394 let fragment = match &item.fragment {
395 Some(fragment) => match resolve_in(fragment, refs, sinks) {
396 Some(fragment) => Some(fragment),
397 None => continue,
398 },
399 None => None,
400 };
401 if let Some((kind, optional, lines)) =
402 merge_expect(status.as_deref(), fragment.as_deref(), out, sinks, at)
403 {
404 let entry = merged.get_or_insert((kind, optional, 0));
405 entry.2 += lines;
406 }
407 }
408 if let Some((kind, optional, lines)) = merged {
412 out.push(LoweredStep {
413 step: step_ref.clone(),
414 kind,
415 payload: StepPayload::MergedAsserts { lines },
416 optional,
417 when: None,
418 label: None,
419 fragment: None,
424 save_as: std::collections::BTreeMap::new(),
425 });
426 }
427 }
428 MacroBody::Steps(steps) => {
429 for macro_step in steps {
430 expand_step(
431 macro_step,
432 step_ref,
433 ctx,
434 depth,
435 out,
436 refs,
437 sinks,
438 at,
439 &resolve_in,
440 &scoped,
441 );
442 }
443 }
444 }
445}
446
447#[allow(clippy::too_many_arguments)]
449fn expand_step(
450 macro_step: &MacroStep,
451 step_ref: &StepRef,
452 ctx: &LowerCtx<'_>,
453 depth: usize,
454 out: &mut Vec<LoweredStep>,
455 refs: &mut Refs,
456 sinks: &mut Sinks,
457 at: &impl Fn(Diag) -> Diag,
458 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
459 scoped: &Bindings,
460) {
461 match ¯o_step.kind {
462 MacroStepKind::Ref { target } => expand_ref_step(
463 target, macro_step, step_ref, ctx, out, refs, sinks, at, resolve_in, scoped,
464 ),
465 MacroStepKind::Use { target, with } => {
466 let Some(target_macro) = ctx.packs.find_use_target(target) else {
467 return; };
469 let mut child_args = BTreeMap::new();
472 for (key, value) in with {
473 if let Some(resolved) = resolve_in(value, refs, sinks) {
474 child_args.insert(key.clone(), resolved);
475 }
476 }
477 expand_macro(
478 target_macro,
479 &child_args,
480 step_ref,
481 ctx,
482 depth + 1,
483 out,
484 refs,
485 sinks,
486 at,
487 );
488 }
489 MacroStepKind::Payload { kind, payload } => expand_payload_step(
490 macro_step, kind, payload, step_ref, out, refs, sinks, resolve_in,
491 ),
492 }
493}
494
495#[allow(clippy::too_many_arguments)]
498fn expand_ref_step(
499 target: &str,
500 macro_step: &MacroStep,
501 step_ref: &StepRef,
502 ctx: &LowerCtx<'_>,
503 out: &mut Vec<LoweredStep>,
504 refs: &mut Refs,
505 sinks: &mut Sinks,
506 at: &impl Fn(Diag) -> Diag,
507 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
508 scoped: &Bindings,
509) {
510 let Some(fragment) = ctx.packs.find_fragment(target) else {
511 return; };
513 let label_fakes_start = refs.fakes;
518 let mut bindings = scoped.clone();
520 bindings.extend(resolve_bindings(
521 ¯o_step.bind,
522 refs,
523 sinks,
524 resolve_in,
525 at,
526 ));
527
528 let unbound: Vec<&str> = fragment
534 .placeholders
535 .iter()
536 .filter(|name| {
537 !bindings.contains_key(name.as_str())
538 && !refs.secrets.contains_key(name.as_str())
539 && !fragment.supplied_variables.contains(name)
548 })
549 .map(String::as_str)
550 .collect();
551 let missing: Vec<&str> = if unbound.is_empty() {
555 Vec::new()
556 } else {
557 let available = captures_before(out);
558 unbound
559 .into_iter()
560 .filter(|name| !available.contains(*name))
561 .collect()
562 };
563 if !missing.is_empty() {
564 let message = format!(
569 "fragment `{}` reads `{}`, which nothing supplies — no `bind:` in scope gives a value, and no earlier step captures it",
570 fragment.name,
571 missing.join("`, `"),
572 );
573 sinks.errors.push(
574 Diag::error("proef::lower::unbound_placeholder", message)
575 .with_source(fragment.file.clone(), Arc::clone(&fragment.source))
576 .maybe_span(crate::pack::locate::line_span(
577 &fragment.source,
578 fragment.line,
579 ))
580 .with_help(format!(
581 "add `bind: {{ {}: … }}` to the step, its macro, or the pack",
582 missing[0]
583 )),
584 );
585 return;
586 }
587
588 let mut literals: BTreeMap<String, String> = BTreeMap::new();
594 for (name, bound) in bindings {
595 match bound {
596 Bound::Secret(secret) => {
597 refs.secrets.insert(name, secret);
598 }
599 Bound::Value(value) => {
600 literals.insert(name, value);
601 }
602 }
603 }
604 let text = bake_entry_options(
605 &fragment.text,
606 macro_step.retry,
607 macro_step.delay_ms,
608 &literals,
609 );
610 finish_step(
611 macro_step,
612 step_ref,
613 StepKindId::from(fragment.kind.as_str()),
614 StepPayload::HurlEntries(text),
615 Some(fragment.qualified()),
619 label_fakes_start,
620 out,
621 refs,
622 sinks,
623 resolve_in,
624 );
625}
626
627#[allow(clippy::too_many_arguments)]
630fn expand_payload_step(
631 macro_step: &MacroStep,
632 kind: &str,
633 payload: &PayloadForm,
634 step_ref: &StepRef,
635 out: &mut Vec<LoweredStep>,
636 refs: &mut Refs,
637 sinks: &mut Sinks,
638 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
639) {
640 let label_fakes_start = refs.fakes;
647 let payload = match payload {
648 PayloadForm::Raw(text) => {
649 let Some(resolved) = resolve_in(text, refs, sinks) else {
650 return;
651 };
652 let resolved = if macro_step.retry.is_some() || macro_step.delay_ms.is_some() {
656 bake_entry_options(
657 &resolved,
658 macro_step.retry,
659 macro_step.delay_ms,
660 &BTreeMap::new(),
661 )
662 } else {
663 resolved
664 };
665 StepPayload::HurlEntries(resolved)
666 }
667 PayloadForm::Structured(value) => {
668 let mut resolve = |text: &str| {
672 if !text.contains('$') {
675 return Some(text.to_owned());
676 }
677 resolve_in(text, refs, sinks)
678 };
679 match resolve_structured(value, &mut resolve) {
680 Some(resolved) => StepPayload::Structured(resolved),
681 None => return,
682 }
683 }
684 };
685 finish_step(
686 macro_step,
687 step_ref,
688 StepKindId::from(kind),
689 payload,
690 None, label_fakes_start,
692 out,
693 refs,
694 sinks,
695 resolve_in,
696 );
697}
698
699#[allow(clippy::too_many_arguments)]
708fn finish_step(
709 macro_step: &MacroStep,
710 step_ref: &StepRef,
711 kind: StepKindId,
712 payload: StepPayload,
713 fragment: Option<String>,
716 label_fakes_start: usize,
717 out: &mut Vec<LoweredStep>,
718 refs: &mut Refs,
719 sinks: &mut Sinks,
720 resolve_in: &impl Fn(&str, &mut Refs, &mut Sinks) -> Option<String>,
721) {
722 let when = match ¯o_step.when {
723 Some(guard) => match resolve_in(guard, refs, sinks) {
724 Some(resolved) => Some(Guard(resolved)),
725 None => return,
726 },
727 None => None,
728 };
729 let functional_fakes_end = refs.fakes;
751 let label = match ¯o_step.name {
752 Some(name) => {
753 refs.fakes = label_fakes_start;
754 let resolved = resolve_in(name, refs, sinks);
755 refs.fakes = functional_fakes_end.max(refs.fakes);
756 match resolved {
757 Some(resolved) => Some(resolved),
758 None => return,
759 }
760 }
761 None => None,
762 };
763 out.push(LoweredStep {
764 step: step_ref.clone(),
765 kind,
766 payload,
767 optional: macro_step.optional,
768 when,
769 label,
770 fragment,
771 save_as: macro_step.save_as.clone(),
772 });
773}
774
775fn resolve_structured(
778 value: &serde_json::Value,
779 resolve: &mut dyn FnMut(&str) -> Option<String>,
780) -> Option<serde_json::Value> {
781 use serde_json::Value as J;
782 Some(match value {
783 J::String(text) => J::String(resolve(text)?),
784 J::Array(items) => J::Array(
785 items
786 .iter()
787 .map(|item| resolve_structured(item, resolve))
788 .collect::<Option<_>>()?,
789 ),
790 J::Object(map) => {
791 let mut out = serde_json::Map::new();
792 for (key, item) in map {
793 out.insert(key.clone(), resolve_structured(item, resolve)?);
794 }
795 J::Object(out)
796 }
797 other => other.clone(),
798 })
799}
800
801fn bake_entry_options(
808 text: &str,
809 retry: Option<crate::step::Retry>,
810 delay_ms: Option<u64>,
811 bindings: &BTreeMap<String, String>,
812) -> String {
813 let mut option_lines: Vec<String> = Vec::new();
814 if let Some(retry) = retry {
815 option_lines.push(format!("retry: {}", retry.count));
816 option_lines.push(format!("retry-interval: {}ms", retry.interval_ms));
817 }
818 if let Some(delay_ms) = delay_ms {
819 option_lines.push(format!("delay: {delay_ms}ms"));
820 }
821 for (name, value) in bindings {
827 option_lines.push(format!("variable: {name}=\"{}\"", quote_option(value)));
828 }
829 if option_lines.is_empty() {
836 return text.to_owned();
837 }
838 let retry_lines = option_lines.join("\n");
839 let mut author_options = vec![false];
844 let mut in_fence = false;
845 for line in text.lines() {
846 let trimmed = line.trim();
847 if trimmed.starts_with("```") {
848 in_fence = !in_fence;
849 continue;
850 }
851 if in_fence {
852 continue;
853 }
854 if is_method_line(trimmed) {
855 author_options.push(false);
856 } else if trimmed == "[Options]"
857 && let Some(last) = author_options.last_mut()
858 {
859 *last = true;
860 }
861 }
862 let has_author_options = |entry: usize| author_options.get(entry).copied().unwrap_or(false);
863 let mut out: Vec<String> = Vec::new();
864 let mut in_entry_head = false; let mut injected_current = false;
866 let mut in_fence = false; let mut entry = 0usize;
868 for line in text.lines() {
869 let trimmed = line.trim();
870 if trimmed.starts_with("```") {
871 if !in_fence && in_entry_head && !injected_current && !has_author_options(entry) {
874 out.push("[Options]".to_owned());
875 out.push(retry_lines.clone());
876 injected_current = true;
877 }
878 in_fence = !in_fence;
879 in_entry_head = false;
880 out.push(line.to_owned());
881 continue;
882 }
883 if in_fence {
884 out.push(line.to_owned());
885 continue;
886 }
887 if is_method_line(trimmed) {
888 in_entry_head = true;
889 injected_current = false;
890 entry += 1;
891 out.push(line.to_owned());
892 continue;
893 }
894 if trimmed == "[Options]" {
895 out.push(line.to_owned());
898 if !injected_current {
899 out.push(retry_lines.clone());
900 injected_current = true;
901 }
902 in_entry_head = false;
903 continue;
904 }
905 let is_header = in_entry_head && is_header_line(trimmed);
906 if in_entry_head && !is_header && !injected_current && !has_author_options(entry) {
907 out.push("[Options]".to_owned());
908 out.push(retry_lines.clone());
909 injected_current = true;
910 in_entry_head = false;
911 }
912 out.push(line.to_owned());
913 }
914 if in_entry_head && !injected_current {
915 out.push("[Options]".to_owned());
916 out.push(retry_lines.clone());
917 }
918 let mut result = out.join("\n");
919 if text.ends_with('\n') {
920 result.push('\n');
921 }
922 result
923}
924
925fn merge_expect(
932 status: Option<&str>,
933 fragment: Option<&str>,
934 out: &mut [LoweredStep],
935 sinks: &mut Sinks,
936 at: &impl Fn(Diag) -> Diag,
937) -> Option<(StepKindId, bool, usize)> {
938 let Some(previous) = out
939 .iter_mut()
940 .rev()
941 .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
942 else {
943 sinks.errors.push(
944 at(Diag::error(
945 "proef::lower::then_before_when",
946 "this assert-only step has no previous request entry to attach to",
947 ))
948 .with_help("a Then step asserts on the request made by an earlier When step"),
949 );
950 return None;
951 };
952
953 if let Some(status) = status
954 && (!status.chars().all(|c| c.is_ascii_digit()) || status.is_empty())
955 {
956 sinks.errors.push(at(Diag::error(
957 "proef::lower::bad_status",
958 format!("expected an HTTP status number, got `{status}`"),
959 )));
960 return None;
961 }
962
963 let host_kind = previous.kind.clone();
964 let host_optional = previous.optional;
965 let StepPayload::HurlEntries(text) = &mut previous.payload else {
966 return None;
967 };
968 let (tail_has_http, tail_has_asserts) = last_entry_scan(text);
974 if !tail_has_http {
975 push_line(text, "HTTP *");
976 }
977 if !tail_has_asserts {
978 push_line(text, "[Asserts]");
979 }
980 let mut appended = 0usize;
981 if let Some(status) = status {
982 push_line(text, &format!("status == {status}"));
983 appended += 1;
984 }
985 if let Some(fragment) = fragment {
986 for line in fragment.lines().filter(|l| !l.trim().is_empty()) {
987 push_line(text, line.trim_end());
988 appended += 1;
989 }
990 }
991 Some((host_kind, host_optional, appended))
992}
993
994fn is_header_line(trimmed: &str) -> bool {
999 let Some((name, _)) = trimmed.split_once(':') else {
1000 return false;
1001 };
1002 !name.is_empty()
1003 && name != "HTTP"
1004 && name
1005 .chars()
1006 .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
1007}
1008
1009pub(crate) fn is_method_line(trimmed: &str) -> bool {
1016 trimmed.split_whitespace().next().is_some_and(|word| {
1017 word.len() >= 3
1018 && word.chars().all(|c| c.is_ascii_uppercase() || c == '-')
1019 && word != "HTTP"
1020 }) && trimmed.split_whitespace().count() >= 2
1021}
1022
1023fn last_entry_scan(text: &str) -> (bool, bool) {
1028 let mut in_fence = false;
1029 let (mut has_http, mut has_asserts) = (false, false);
1030 for line in text.lines() {
1031 let trimmed = line.trim();
1032 if trimmed.starts_with("```") {
1033 in_fence = !in_fence;
1034 continue;
1035 }
1036 if in_fence {
1037 continue;
1038 }
1039 if is_method_line(trimmed) {
1040 (has_http, has_asserts) = (false, false);
1041 continue;
1042 }
1043 has_http = has_http || trimmed.starts_with("HTTP");
1044 has_asserts = has_asserts || trimmed == "[Asserts]";
1045 }
1046 (has_http, has_asserts)
1047}
1048
1049fn push_line(text: &mut String, line: &str) {
1050 if !text.is_empty() && !text.ends_with('\n') {
1051 text.push('\n');
1052 }
1053 text.push_str(line);
1054 text.push('\n');
1055}
1056
1057fn segment(steps: Vec<LoweredStep>, kind_to_engine: &BTreeMap<String, String>) -> Vec<StepBatch> {
1061 let mut batches: Vec<StepBatch> = Vec::new();
1062 for step in steps {
1063 let engine = kind_to_engine
1067 .get(step.kind.as_str())
1068 .map_or_else(|| step.kind.as_str().to_owned(), Clone::clone);
1069 let glued = matches!(step.payload, StepPayload::MergedAsserts { .. });
1072 let start_new = match batches.last() {
1073 None => true,
1074 Some(last) => {
1075 !glued
1076 && (last.engine.as_str() != engine
1077 || step.optional
1078 || last.steps.last().is_some_and(|s| s.optional))
1079 }
1080 };
1081 if start_new {
1082 batches.push(StepBatch {
1083 index: batches.len(),
1084 engine: crate::engine::EngineId::from(engine.as_str()),
1085 steps: vec![step],
1086 });
1087 } else if let Some(last) = batches.last_mut() {
1088 last.steps.push(step);
1089 }
1090 }
1091 batches
1092}
1093
1094fn push_warnings(sinks: &mut Sinks, texts: &[String], ctx: &LowerCtx<'_>, where_: &str) {
1095 for text in texts {
1096 sinks.warnings.push(
1097 Diag::warning("proef::lower::dry_run_unknown", format!("{where_}: {text}"))
1098 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
1099 );
1100 }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105 #![allow(clippy::unwrap_used, clippy::expect_used)]
1106
1107 use super::*;
1108 use crate::engine::StepKindSpec;
1109 use crate::pack::{self, PackSource};
1110 use crate::step::StepPayload;
1111
1112 const KINDS: &[StepKindSpec] = &[StepKindSpec {
1113 prefix: "hurl",
1114 schema: "true",
1115 validate: None,
1116 fragments: None,
1117 }];
1118
1119 const PACK: &str = r#"macros:
1120 auth:
1121 params: [token]
1122 steps:
1123 - name: authenticate
1124 hurl: |
1125 POST ${url:base}/auth
1126 Authorization: Bearer ${token}
1127 HTTP 200
1128 search:
1129 params: [term]
1130 match: "I search for {term}"
1131 steps:
1132 - use: auth
1133 with: { token: "${secret:apiToken}" }
1134 - name: run the search
1135 hurl: |
1136 GET ${url:base}/search?q=${term}
1137 HTTP 200
1138 [Captures]
1139 recordId: jsonpath "$[0].id"
1140 checkHealth:
1141 match: the service is healthy
1142 steps:
1143 - optional: true
1144 hurl: |
1145 GET ${url:base}/health
1146 expectStatus:
1147 params: [status]
1148 match: "the response status is {status}"
1149 expect:
1150 - status: "${status}"
1151"#;
1152
1153 fn fixture() -> (
1154 crate::feature::FeatureFile,
1155 crate::bind::BoundScenario,
1156 PackSet,
1157 ) {
1158 let packs = pack::load(
1159 &[PackSource {
1160 name: "test.yaml".into(),
1161 text: Arc::from(PACK),
1162 }],
1163 &crate::pack::FragmentCorpus::empty(),
1164 KINDS,
1165 )
1166 .unwrap();
1167 let feature = crate::feature::parse(
1168 "t.feature",
1169 "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",
1170 )
1171 .unwrap();
1172 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1173 (feature, scenario, packs)
1174 }
1175
1176 fn ctx<'a>(
1177 feature: &'a crate::feature::FeatureFile,
1178 packs: &'a PackSet,
1179 kind_to_engine: &'a BTreeMap<String, String>,
1180 env: &'a BTreeMap<String, String>,
1181 config_vars: &'a BTreeMap<String, String>,
1182 world: &'a World,
1183 ) -> LowerCtx<'a> {
1184 LowerCtx {
1185 feature,
1186 packs,
1187 kind_to_engine,
1188 env,
1189 config_vars,
1190 run_id: "run-0001",
1191 world,
1192 mode: ResolveMode::DryRun,
1193 }
1194 }
1195
1196 #[test]
1197 fn expansion_resolution_merge_and_segmentation_work_together() {
1198 let (feature, scenario, packs) = fixture();
1199 let kind_to_engine: BTreeMap<String, String> =
1200 [("hurl".to_owned(), "hurl".to_owned())].into();
1201 let env = BTreeMap::new();
1202 let config_vars =
1203 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1204 let world = World::default();
1205 let lowered = lower(
1206 &scenario,
1207 &ctx(
1208 &feature,
1209 &packs,
1210 &kind_to_engine,
1211 &env,
1212 &config_vars,
1213 &world,
1214 ),
1215 )
1216 .unwrap();
1217
1218 assert_eq!(lowered.batches.len(), 2);
1222 assert_eq!(lowered.batches[0].steps.len(), 1);
1223 assert!(lowered.batches[0].steps[0].optional);
1224 assert_eq!(lowered.batches[1].steps.len(), 3);
1225 let StepPayload::MergedAsserts { lines } = lowered.batches[1].steps[2].payload else {
1226 panic!("expected a merged-asserts step for the Then line");
1227 };
1228 assert_eq!(lines, 1, "the expect appended exactly `status == 200`");
1229
1230 let StepPayload::HurlEntries(auth) = &lowered.batches[1].steps[0].payload else {
1232 panic!("expected hurl entries");
1233 };
1234 assert!(auth.contains("POST http://fixture.local/auth"), "{auth}");
1235 assert!(
1236 auth.contains("Bearer {{apiToken}}"),
1237 "secret placeholder: {auth}"
1238 );
1239 assert!(lowered.secrets.contains_key("apiToken"));
1240
1241 let StepPayload::HurlEntries(search) = &lowered.batches[1].steps[1].payload else {
1243 panic!("expected hurl entries");
1244 };
1245 assert!(
1246 search.contains("GET http://fixture.local/search?q=Jansen"),
1247 "{search}"
1248 );
1249 assert!(search.contains("[Asserts]"), "{search}");
1250 assert!(search.trim_end().ends_with("status == 200"), "{search}");
1251
1252 assert_eq!(lowered.batches[1].steps[1].step.line, 4);
1254 assert_eq!(
1255 lowered.batches[1].steps[0].label.as_deref(),
1256 Some("authenticate")
1257 );
1258 }
1259
1260 #[test]
1261 fn then_before_when_is_an_error() {
1262 let (_, _, packs) = fixture();
1263 let feature = crate::feature::parse(
1264 "t.feature",
1265 "Feature: F\n Scenario: S\n Then the response status is 200\n",
1266 )
1267 .unwrap();
1268 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1269 let kind_to_engine = BTreeMap::new();
1270 let env = BTreeMap::new();
1271 let config_vars = BTreeMap::new();
1272 let world = World::default();
1273 let errs = lower(
1274 &scenario,
1275 &ctx(
1276 &feature,
1277 &packs,
1278 &kind_to_engine,
1279 &env,
1280 &config_vars,
1281 &world,
1282 ),
1283 )
1284 .unwrap_err();
1285 assert_eq!(errs[0].code, "proef::lower::then_before_when");
1286 }
1287
1288 #[test]
1298 fn an_expect_fragment_that_resolves_empty_does_not_invert_the_merged_span() {
1299 const PACK: &str = r#"macros:
1300 ping:
1301 match: the service is pinged
1302 steps:
1303 - hurl: |
1304 GET ${url:base}/ping
1305 HTTP 200
1306 expectBlank:
1307 match: nothing extra is asserted
1308 expect:
1309 - hurl: "${vars:blank}"
1310"#;
1311 let packs = pack::load(
1312 &[PackSource {
1313 name: "test.yaml".into(),
1314 text: Arc::from(PACK),
1315 }],
1316 &crate::pack::FragmentCorpus::empty(),
1317 KINDS,
1318 )
1319 .unwrap();
1320 let feature = crate::feature::parse(
1321 "t.feature",
1322 "Feature: F\n Scenario: S\n Given the service is pinged\n Then nothing extra is asserted\n",
1323 )
1324 .unwrap();
1325 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1326 let kind_to_engine: BTreeMap<String, String> =
1327 [("hurl".to_owned(), "hurl".to_owned())].into();
1328 let env = BTreeMap::new();
1329 let config_vars = BTreeMap::from([
1330 ("url:base".to_owned(), "http://fixture.local".to_owned()),
1331 ("vars:blank".to_owned(), String::new()),
1332 ]);
1333 let world = World::default();
1334 let lowered = lower(
1335 &scenario,
1336 &ctx(
1337 &feature,
1338 &packs,
1339 &kind_to_engine,
1340 &env,
1341 &config_vars,
1342 &world,
1343 ),
1344 )
1345 .unwrap();
1346
1347 assert_eq!(lowered.batches[0].steps.len(), 2);
1348 let StepPayload::MergedAsserts { lines } = lowered.batches[0].steps[1].payload else {
1349 panic!("expected a merged-asserts step for the Then line");
1350 };
1351 assert_eq!(lines, 0, "the fragment resolved to nothing");
1352
1353 let artifact = crate::emit::emit(&lowered, "t", &world).unwrap();
1354 for entry in &artifact.map.entries {
1355 let [start, end] = entry.hurl_lines;
1356 assert!(
1357 start <= end,
1358 "inverted span for a zero-line merge: {start}..{end}"
1359 );
1360 }
1361 }
1362
1363 #[test]
1366 fn structured_payloads_resolve_placeholders_recursively() {
1367 const ALT_KINDS: &[StepKindSpec] = &[StepKindSpec {
1368 prefix: "alt",
1369 schema: "true",
1370 validate: None,
1371 fragments: None,
1372 }];
1373 let packs = pack::load(
1374 &[PackSource {
1375 name: "alt.yaml".into(),
1376 text: Arc::from(
1377 "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",
1378 ),
1379 }],
1380 &crate::pack::FragmentCorpus::empty(),
1381 ALT_KINDS,
1382 )
1383 .unwrap();
1384 let feature = crate::feature::parse(
1385 "t.feature",
1386 "Feature: F\n Scenario: S\n When the alternate step runs\n",
1387 )
1388 .unwrap();
1389 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1390 let kind_to_engine: BTreeMap<String, String> =
1391 [("alt".to_owned(), "alt".to_owned())].into();
1392 let env = BTreeMap::new();
1393 let config_vars =
1394 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1395 let world = World::default();
1396 let lowered = lower(
1397 &scenario,
1398 &ctx(
1399 &feature,
1400 &packs,
1401 &kind_to_engine,
1402 &env,
1403 &config_vars,
1404 &world,
1405 ),
1406 )
1407 .unwrap();
1408 let StepPayload::Structured(value) = &lowered.batches[0].steps[0].payload else {
1409 panic!("structured payload expected");
1410 };
1411 assert_eq!(value["target"], "http://fixture.local/item");
1412 assert_eq!(value["checks"][0], "http://fixture.local");
1413 assert_eq!(value["checks"][1], 7);
1414 }
1415
1416 #[test]
1420 fn expect_merge_scopes_to_the_last_entry() {
1421 let packs = pack::load(
1422 &[PackSource {
1423 name: "multi.yaml".into(),
1424 text: Arc::from(
1425 "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",
1426 ),
1427 }],
1428 &crate::pack::FragmentCorpus::empty(),
1429 KINDS,
1430 )
1431 .unwrap();
1432 let feature = crate::feature::parse(
1433 "t.feature",
1434 "Feature: F\n Scenario: S\n When both calls run\n Then the response status is 201\n",
1435 )
1436 .unwrap();
1437 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1438 let kind_to_engine: BTreeMap<String, String> =
1439 [("hurl".to_owned(), "hurl".to_owned())].into();
1440 let env = BTreeMap::new();
1441 let config_vars = BTreeMap::new();
1442 let world = World::default();
1443 let lowered = lower(
1444 &scenario,
1445 &ctx(
1446 &feature,
1447 &packs,
1448 &kind_to_engine,
1449 &env,
1450 &config_vars,
1451 &world,
1452 ),
1453 )
1454 .unwrap();
1455 let StepPayload::HurlEntries(text) = &lowered.batches[0].steps[0].payload else {
1456 panic!("expected hurl entries");
1457 };
1458 let tail = text.split("GET http://x/b").nth(1).unwrap();
1462 assert!(tail.contains("HTTP *"), "{text}");
1463 assert!(tail.contains("[Asserts]"), "{text}");
1464 assert!(tail.contains("status == 201"), "{text}");
1465 }
1466
1467 #[test]
1471 fn baked_options_extend_a_late_author_options_section() {
1472 let retry = Some(crate::step::Retry {
1473 count: 2,
1474 interval_ms: 100,
1475 });
1476 let body =
1477 "GET http://x/a\n[QueryStringParams]\nq: 1\n[Options]\nverbose: true\nHTTP 200\n";
1478 let baked = bake_entry_options(body, retry, None, &BTreeMap::new());
1479 assert_eq!(baked.matches("[Options]").count(), 1, "{baked}");
1480 assert!(
1481 baked.contains("[Options]\nretry: 2\nretry-interval: 100ms\nverbose: true"),
1482 "{baked}"
1483 );
1484 }
1485
1486 #[test]
1489 fn baked_options_never_enter_bodies() {
1490 let retry = Some(crate::step::Retry {
1491 count: 2,
1492 interval_ms: 100,
1493 });
1494 for body in [
1495 "POST http://x/a\n```\nNOTE FOR REVIEW\nsecond line\n```\nHTTP 200\n",
1496 "POST http://x/a\n<root xmlns:x=\"urn:example\">\n <child>hi</child>\n</root>\nHTTP 200\n",
1497 "POST http://x/a\n{\"note\": \"FOR REVIEW\"}\nHTTP 200\n",
1498 ] {
1499 let baked = bake_entry_options(body, retry, None, &BTreeMap::new());
1500 assert_eq!(
1501 baked.matches("[Options]").count(),
1502 1,
1503 "exactly one options block in:\n{baked}"
1504 );
1505 let options_at = baked.find("[Options]").unwrap_or(usize::MAX);
1506 let body_at = baked
1507 .find("```")
1508 .or_else(|| baked.find('<'))
1509 .or_else(|| baked.find('{'))
1510 .unwrap_or(0);
1511 assert!(options_at < body_at, "options precede the body:\n{baked}");
1512 }
1513 }
1514
1515 #[test]
1516 fn engine_change_splits_batches() {
1517 let steps: Vec<LoweredStep> = ["hurl", "hurl", "alt", "hurl"]
1518 .iter()
1519 .map(|kind| LoweredStep {
1520 step: StepRef {
1521 file: Arc::from("f"),
1522 line: 1,
1523 text: Arc::from("t"),
1524 },
1525 kind: StepKindId::from(*kind),
1526 payload: StepPayload::HurlEntries(String::new()),
1527 optional: false,
1528 when: None,
1529 label: None,
1530 fragment: None,
1531 save_as: BTreeMap::new(),
1532 })
1533 .collect();
1534 let mapping: BTreeMap<String, String> = [
1535 ("hurl".to_owned(), "hurl".to_owned()),
1536 ("alt".to_owned(), "alt".to_owned()),
1537 ]
1538 .into();
1539 let batches = segment(steps, &mapping);
1540 let sizes: Vec<usize> = batches.iter().map(|b| b.steps.len()).collect();
1541 assert_eq!(sizes, vec![2, 1, 1]);
1542 assert_eq!(batches[1].engine.as_str(), "alt");
1543 let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
1545 assert_eq!(indexes, vec![0, 1, 2]);
1546 }
1547
1548 #[test]
1555 fn label_mirrors_the_payloads_fake_values_without_shifting_later_steps() {
1556 const FAKE_PACK: &str = r#"macros:
1557 searchFor:
1558 params: [term]
1559 match: "the operator searches for {term}"
1560 steps:
1561 - name: "search for ${term}"
1562 hurl: |
1563 GET ${url:base}/search
1564 [Query]
1565 q: ${term}
1566 HTTP 200
1567 pingFake:
1568 match: a fresh fake is requested
1569 steps:
1570 - hurl: |
1571 GET ${url:base}/ping
1572 [Query]
1573 v: ${fake:lastName}
1574 HTTP 200
1575"#;
1576 let packs = pack::load(
1577 &[PackSource {
1578 name: "fakes.yaml".into(),
1579 text: Arc::from(FAKE_PACK),
1580 }],
1581 &crate::pack::FragmentCorpus::empty(),
1582 KINDS,
1583 )
1584 .unwrap();
1585 let feature = crate::feature::parse(
1586 "t.feature",
1587 "Feature: F\n Scenario: S\n When the operator searches for ${fake:lastName}\n Then a fresh fake is requested\n",
1588 )
1589 .unwrap();
1590 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1591 let kind_to_engine: BTreeMap<String, String> =
1592 [("hurl".to_owned(), "hurl".to_owned())].into();
1593 let env = BTreeMap::new();
1594 let config_vars =
1595 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1596 let world = World::default();
1597 let lowered = lower(
1598 &scenario,
1599 &ctx(
1600 &feature,
1601 &packs,
1602 &kind_to_engine,
1603 &env,
1604 &config_vars,
1605 &world,
1606 ),
1607 )
1608 .unwrap();
1609
1610 assert_eq!(lowered.batches[0].steps.len(), 2);
1612 let StepPayload::HurlEntries(search) = &lowered.batches[0].steps[0].payload else {
1613 panic!("expected hurl entries");
1614 };
1615 let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1616
1617 let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1620 assert!(
1621 search.contains(&format!("q: {occurrence_0}")),
1622 "payload: {search}"
1623 );
1624 assert!(label.contains(&occurrence_0), "label: {label}");
1625
1626 let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1629 panic!("expected hurl entries");
1630 };
1631 let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1632 assert!(ping.contains(&format!("v: {occurrence_1}")), "ping: {ping}");
1633 }
1634
1635 #[test]
1642 fn label_with_more_fakes_than_its_payload_does_not_leak_occurrences_to_later_steps() {
1643 const FAKE_PACK: &str = r#"macros:
1644 unmirroredLabel:
1645 match: a label mentions more fakes than its payload
1646 steps:
1647 - name: "${fake:lastName} vs ${fake:lastName}"
1648 hurl: |
1649 GET ${url:base}/probe
1650 [Query]
1651 q: ${fake:lastName}
1652 HTTP 200
1653 pingFake:
1654 match: a fresh fake is requested
1655 steps:
1656 - hurl: |
1657 GET ${url:base}/ping
1658 [Query]
1659 v: ${fake:lastName}
1660 HTTP 200
1661"#;
1662 let packs = pack::load(
1663 &[PackSource {
1664 name: "unmirrored.yaml".into(),
1665 text: Arc::from(FAKE_PACK),
1666 }],
1667 &crate::pack::FragmentCorpus::empty(),
1668 KINDS,
1669 )
1670 .unwrap();
1671 let feature = crate::feature::parse(
1672 "t.feature",
1673 "Feature: F\n Scenario: S\n When a label mentions more fakes than its payload\n Then a fresh fake is requested\n",
1674 )
1675 .unwrap();
1676 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1677 let kind_to_engine: BTreeMap<String, String> =
1678 [("hurl".to_owned(), "hurl".to_owned())].into();
1679 let env = BTreeMap::new();
1680 let config_vars =
1681 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1682 let world = World::default();
1683 let lowered = lower(
1684 &scenario,
1685 &ctx(
1686 &feature,
1687 &packs,
1688 &kind_to_engine,
1689 &env,
1690 &config_vars,
1691 &world,
1692 ),
1693 )
1694 .unwrap();
1695
1696 assert_eq!(lowered.batches[0].steps.len(), 2);
1698 let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1699 let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1700 panic!("expected hurl entries");
1701 };
1702
1703 let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1707 let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1708 assert!(label.contains(&occurrence_0), "label: {label}");
1709 assert!(label.contains(&occurrence_1), "label: {label}");
1710
1711 let occurrence_2 = crate::fake::generate("run-0001", 2, "lastName").unwrap();
1715 assert!(
1716 !ping.contains(&format!("v: {occurrence_1}")),
1717 "the next step's fake reused an occurrence the label already \
1718 displayed: {ping}"
1719 );
1720 assert!(ping.contains(&format!("v: {occurrence_2}")), "ping: {ping}");
1721 }
1722
1723 #[allow(clippy::unnecessary_wraps)]
1732 fn frag_scan(
1733 text: &str,
1734 ) -> Result<Vec<crate::engine::ScannedFragment>, crate::engine::FragmentScanError> {
1735 let mut out: Vec<crate::engine::ScannedFragment> = Vec::new();
1736 for (index, line) in text.lines().enumerate() {
1737 let line = line.trim();
1738 if let Some(name) = line.strip_prefix('@') {
1739 out.push(crate::engine::ScannedFragment {
1740 name: name.to_owned(),
1741 text: format!("GET http://x/{name}\nHTTP 200\n"),
1742 line: index + 1,
1743 placeholders: Vec::new(),
1744 declared_options: Vec::new(),
1745 supplied_variables: Vec::new(),
1746 });
1747 } else if let Some(last) = out.last_mut() {
1748 if let Some(read) = line.strip_prefix('?') {
1749 last.placeholders.push(read.to_owned());
1750 } else if let Some(supplied) = line.strip_prefix('=') {
1751 let head = last.text.find("HTTP ").unwrap_or(last.text.len());
1757 let section = if last.text[..head].contains("[Options]") {
1758 format!("variable: {supplied}=from-fragment\n")
1759 } else {
1760 format!("[Options]\nvariable: {supplied}=from-fragment\n")
1761 };
1762 last.text.insert_str(head, §ion);
1763 last.supplied_variables.push(supplied.to_owned());
1764 } else if let Some(write) = line.strip_prefix('!') {
1765 if !last.text.contains("[Captures]") {
1769 last.text.push_str("[Captures]\n");
1770 }
1771 last.text.push_str(write);
1772 last.text.push_str(": jsonpath \"$.id\"\n");
1773 }
1774 }
1775 }
1776 Ok(out)
1777 }
1778
1779 const FRAG_KINDS: &[StepKindSpec] = &[StepKindSpec {
1780 prefix: "hurl",
1781 schema: "true",
1782 validate: None,
1783 fragments: Some(crate::engine::FragmentSupport {
1784 ext: "frag",
1785 scan: frag_scan,
1786 }),
1787 }];
1788
1789 fn lower_fragments(pack: &str, fragments: &str) -> Result<LoweredScenario, Vec<Diag>> {
1791 let packs = pack::load(
1792 &[PackSource {
1793 name: "p.yaml".into(),
1794 text: Arc::from(pack),
1795 }],
1796 &pack::FragmentCorpus::new(
1797 vec![PackSource {
1798 name: "api.frag".into(),
1799 text: Arc::from(fragments),
1800 }],
1801 FRAG_KINDS,
1802 ),
1803 FRAG_KINDS,
1804 )
1805 .unwrap_or_else(|err| panic!("pack should load: {err:?}"));
1806 let feature =
1807 crate::feature::parse("t.feature", "Feature: F\n Scenario: S\n When it runs\n")
1808 .unwrap();
1809 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1810 let kind_to_engine = BTreeMap::from([("hurl".to_owned(), "hurl".to_owned())]);
1811 let env = BTreeMap::new();
1812 let config_vars = BTreeMap::from([("url:base".to_owned(), "http://api".to_owned())]);
1813 let world = World::new(crate::world::GlobalStore::default());
1814 let ctx = ctx(
1815 &feature,
1816 &packs,
1817 &kind_to_engine,
1818 &env,
1819 &config_vars,
1820 &world,
1821 );
1822 lower(&scenario, &ctx)
1823 }
1824
1825 fn only_entry(lowered: &LoweredScenario) -> &str {
1826 let step = lowered
1827 .batches
1828 .iter()
1829 .flat_map(|b| b.steps.iter())
1830 .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
1831 .expect("one hurl entry");
1832 let StepPayload::HurlEntries(text) = &step.payload else {
1833 unreachable!()
1834 };
1835 text
1836 }
1837
1838 #[test]
1842 fn bindings_cascade_and_are_injected_as_entry_options() {
1843 let lowered = lower_fragments(
1844 "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",
1845 "@f\n?base\n?who\n?extra\n",
1846 )
1847 .expect("lowers");
1848 let text = only_entry(&lowered);
1849 assert!(text.contains("[Options]"), "{text}");
1850 assert!(text.contains(r#"variable: base="http://api""#), "{text}");
1851 assert!(
1852 text.contains(r#"variable: who="step""#),
1853 "step scope wins: {text}"
1854 );
1855 assert!(!text.contains(r#"who="macro""#) && !text.contains(r#"who="pack""#));
1856 assert!(text.contains(r#"variable: extra="yes""#), "{text}");
1857 }
1858
1859 #[test]
1863 fn a_secret_binding_is_renamed_not_written() {
1864 let lowered = lower_fragments(
1865 "macros:\n m:\n match: it runs\n bind:\n auth_token: ${secret:apiToken}\n steps:\n - ref: f\n",
1866 "@f\n?auth_token\n",
1867 )
1868 .expect("lowers");
1869 let text = only_entry(&lowered);
1870 assert!(
1871 !text.contains("auth_token=") && !text.contains("apiToken"),
1872 "no secret may reach the artifact: {text}"
1873 );
1874 assert_eq!(
1875 lowered.secrets.get("auth_token").map(String::as_str),
1876 Some("apiToken")
1877 );
1878 }
1879
1880 #[test]
1881 fn a_secret_mixed_into_a_larger_value_is_refused() {
1882 let diags = lower_fragments(
1883 "macros:\n m:\n match: it runs\n bind:\n auth: \"Bearer ${secret:apiToken}\"\n steps:\n - ref: f\n",
1884 "@f\n?auth\n",
1885 )
1886 .expect_err("should refuse");
1887 assert!(
1888 diags
1889 .iter()
1890 .any(|d| d.code == "proef::lower::secret_in_composite_bind"),
1891 "{diags:?}"
1892 );
1893 }
1894
1895 #[test]
1900 fn an_escaped_secret_reference_is_a_literal_not_a_secret() {
1901 let lowered = lower_fragments(
1902 "macros:\n m:\n match: it runs\n bind:\n hint: $${secret:apiToken}\n steps:\n - ref: f\n",
1903 "@f\n?hint\n",
1904 )
1905 .expect("an escaped reference is ordinary text");
1906 assert!(
1907 lowered.secrets.is_empty(),
1908 "nothing was bound to a secret: {:?}",
1909 lowered.secrets
1910 );
1911 assert!(
1912 only_entry(&lowered).contains(r#"variable: hint="${secret:apiToken}""#),
1913 "the literal is injected verbatim: {}",
1914 only_entry(&lowered)
1915 );
1916 }
1917
1918 #[test]
1923 fn a_variable_the_fragment_supplies_itself_needs_no_binding() {
1924 let lowered = lower_fragments(
1925 "macros:\n m:\n match: it runs\n steps:\n - ref: first\n",
1926 "@first\n=token\n?token\n",
1927 )
1928 .expect("a fragment that supplies its own variable lowers");
1929 let text = lowered
1930 .batches
1931 .iter()
1932 .flat_map(|b| b.steps.iter())
1933 .find_map(|s| match &s.payload {
1934 StepPayload::HurlEntries(text) => Some(text.clone()),
1935 _ => None,
1936 })
1937 .expect("hurl entries");
1938 assert_eq!(
1941 text.matches("variable: token=").count(),
1942 1,
1943 "exactly one supplier reaches the entry: {text}"
1944 );
1945 }
1946
1947 #[test]
1951 fn a_placeholder_nothing_supplies_is_refused() {
1952 let diags = lower_fragments(
1953 "macros:\n m:\n match: it runs\n steps:\n - ref: f\n",
1954 "@f\n?missingOne\n",
1955 )
1956 .expect_err("should refuse");
1957 let diag = diags
1958 .iter()
1959 .find(|d| d.code == "proef::lower::unbound_placeholder")
1960 .unwrap_or_else(|| panic!("expected unbound_placeholder in {diags:?}"));
1961 assert!(diag.message.contains("missingOne"), "{}", diag.message);
1962 assert!(diag.help.is_some());
1963 }
1964
1965 #[test]
1972 fn one_binding_is_one_value_across_a_macros_steps() {
1973 let lowered = lower_fragments(
1974 "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",
1975 "@first\n?shared\n?own\n@second\n?shared\n?own\n",
1976 )
1977 .expect("lowers");
1978 let entries: Vec<&str> = lowered
1979 .batches
1980 .iter()
1981 .flat_map(|b| b.steps.iter())
1982 .filter_map(|s| match &s.payload {
1983 StepPayload::HurlEntries(text) => Some(text.as_str()),
1984 _ => None,
1985 })
1986 .collect();
1987 assert_eq!(entries.len(), 2);
1988 let shared = |text: &str| {
1989 text.lines()
1990 .find(|l| l.starts_with("variable: shared="))
1991 .expect("shared binding")
1992 .to_owned()
1993 };
1994 let own = |text: &str| {
1995 text.lines()
1996 .find(|l| l.starts_with("variable: own="))
1997 .expect("own binding")
1998 .to_owned()
1999 };
2000 assert_eq!(
2001 shared(entries[0]),
2002 shared(entries[1]),
2003 "one macro-scope binding is one value for the whole macro"
2004 );
2005 assert_ne!(
2006 own(entries[0]),
2007 own(entries[1]),
2008 "two step-scope bindings are two values"
2009 );
2010 }
2011
2012 #[test]
2018 fn a_ref_steps_label_replays_its_binding_instead_of_minting_a_fresh_value() {
2019 let labelled = lower_fragments(
2020 "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",
2021 "@first\n?who\n@second\n?who\n",
2022 )
2023 .expect("lowers");
2024 let steps: Vec<&LoweredStep> = labelled
2025 .batches
2026 .iter()
2027 .flat_map(|b| b.steps.iter())
2028 .collect();
2029 assert_eq!(steps.len(), 2);
2030 let who = |step: &LoweredStep| {
2031 let StepPayload::HurlEntries(text) = &step.payload else {
2032 unreachable!()
2033 };
2034 text.lines()
2035 .find_map(|l| l.strip_prefix("variable: who="))
2036 .expect("who binding")
2037 .trim_matches('"')
2038 .to_owned()
2039 };
2040 assert_eq!(
2041 steps[0].label.as_deref(),
2042 Some(format!("signup {}", who(steps[0])).as_str()),
2043 "the label must report the value its own binding sent"
2044 );
2045
2046 let control = lower_fragments(
2049 "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",
2050 "@first\n?who\n@second\n?who\n",
2051 )
2052 .expect("lowers");
2053 let control_steps: Vec<&LoweredStep> = control
2054 .batches
2055 .iter()
2056 .flat_map(|b| b.steps.iter())
2057 .collect();
2058 assert_eq!(
2059 who(steps[1]),
2060 who(control_steps[1]),
2061 "a label must not shift a later step's fake values"
2062 );
2063 }
2064
2065 mod properties {
2066 #![allow(clippy::ignored_unit_patterns)]
2067
2068 use super::*;
2069 use proptest::prelude::*;
2070
2071 proptest! {
2072 #[test]
2082 fn a_renamed_secret_binds_by_name_and_never_by_value(
2083 variable in "[a-z][a-z_]{2,12}",
2084 secret in "[a-zA-Z][a-zA-Z0-9]{3,12}",
2085 literal in "[a-z][a-z0-9]{2,10}",
2086 ) {
2087 prop_assume!(variable != "plain");
2090 let pack = format!(
2091 "macros:\n m:\n match: it runs\n bind:\n {variable}: ${{secret:{secret}}}\n plain: {literal}\n steps:\n - ref: f\n"
2092 );
2093 let fragments = format!("@f\n?{variable}\n?plain\n");
2094 let lowered = lower_fragments(&pack, &fragments)
2095 .unwrap_or_else(|d| panic!("should lower: {d:?}"));
2096 let text = only_entry(&lowered);
2097
2098 let variable_line = format!("variable: {variable}=");
2101 prop_assert!(!text.contains(&variable_line));
2102 prop_assert!(!text.contains(&secret));
2103 let plain_line = format!("variable: plain=\"{literal}\"");
2106 prop_assert!(text.contains(&plain_line));
2107 prop_assert_eq!(
2109 lowered.secrets.get(&variable).map(String::as_str),
2110 Some(secret.as_str())
2111 );
2112 }
2113 }
2114 }
2115
2116 #[test]
2119 fn a_capture_from_an_earlier_step_supplies_a_later_fragment() {
2120 lower_fragments(
2121 "macros:\n m:\n match: it runs\n steps:\n - ref: first\n - ref: second\n",
2122 "@first\n!recordId\n@second\n?recordId\n",
2123 )
2124 .expect("a preceding capture supplies it");
2125 }
2126}