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: BTreeSet<String>,
58 pub globals: BTreeSet<String>,
60 pub warnings: Vec<Diag>,
62}
63
64const MAX_EXPANSION_DEPTH: usize = 32;
66
67#[derive(Debug, Default)]
69struct Refs {
70 secrets: BTreeSet<String>,
71 globals: BTreeSet<String>,
72 fakes: usize,
80}
81
82pub fn lower(scenario: &BoundScenario, ctx: &LowerCtx<'_>) -> Result<LoweredScenario, Vec<Diag>> {
84 let mut diags: Vec<Diag> = Vec::new();
85 let mut warnings: Vec<Diag> = Vec::new();
86 let mut refs = Refs::default();
87 let mut lowered: Vec<LoweredStep> = Vec::new();
88 for step in &scenario.steps {
89 let step_ref = StepRef {
90 file: Arc::from(ctx.feature.path.as_str()),
91 line: step.defn.line,
92 text: Arc::from(step.defn.text.as_str()),
93 };
94 let at = |diag: Diag| {
95 diag.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source))
96 .with_span(step.defn.span)
97 };
98 let Some(macro_) = ctx.packs.macros.get(&step.macro_name) else {
99 continue; };
101 expand_macro(
102 macro_,
103 &step.args,
104 &step_ref,
105 ctx,
106 0,
107 &mut lowered,
108 &mut refs,
109 &mut warnings,
110 &mut diags,
111 &at,
112 );
113 }
114
115 for step in &lowered {
121 if matches!(step.payload, StepPayload::MergedAsserts { .. }) {
122 continue; }
124 if !ctx.kind_to_engine.contains_key(step.kind.as_str()) {
125 diags.push(
126 Diag::error(
127 "proef::lower::kind_unrouted",
128 format!(
129 "internal: step kind `{}` is not claimed by any registered engine \
130 (registry/pack-validation drift)",
131 step.kind.as_str()
132 ),
133 )
134 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
135 );
136 }
137 }
138
139 if diags.iter().any(|d| d.severity == Severity::Error) {
140 return Err(diags);
141 }
142
143 Ok(LoweredScenario {
144 name: scenario.name.clone(),
145 tags: scenario.tags.clone(),
146 line: scenario.line,
147 batches: segment(lowered, ctx.kind_to_engine),
148 secrets: refs.secrets,
149 globals: refs.globals,
150 warnings,
151 })
152}
153
154#[allow(clippy::too_many_arguments)]
156fn expand_macro(
157 macro_: &Macro,
158 args: &BTreeMap<String, String>,
159 step_ref: &StepRef,
160 ctx: &LowerCtx<'_>,
161 depth: usize,
162 out: &mut Vec<LoweredStep>,
163 refs: &mut Refs,
164 warnings: &mut Vec<Diag>,
165 diags: &mut Vec<Diag>,
166 at: &impl Fn(Diag) -> Diag,
167) {
168 if depth > MAX_EXPANSION_DEPTH {
169 diags.push(at(Diag::error(
170 "proef::lower::expansion_too_deep",
171 format!(
172 "macro expansion exceeded depth {MAX_EXPANSION_DEPTH} at `{}`",
173 macro_.name
174 ),
175 )));
176 return;
177 }
178
179 let resolve_in = |text: &str,
180 refs: &mut Refs,
181 warnings: &mut Vec<Diag>,
182 diags: &mut Vec<Diag>|
183 -> Option<String> {
184 let resolve_ctx = ResolveCtx {
185 args,
186 defaults: ¯o_.defaults,
187 env: ctx.env,
188 config_vars: ctx.config_vars,
189 run_id: ctx.run_id,
190 world: ctx.world,
191 mode: ctx.mode,
192 };
193 match resolve::resolve(text, &resolve_ctx, &mut refs.fakes) {
194 Ok(resolution) => {
195 refs.secrets.extend(resolution.secrets);
196 refs.globals.extend(resolution.globals);
197 push_warnings(warnings, &resolution.warnings, ctx, ¯o_.name);
198 Some(resolution.text)
199 }
200 Err(err) => {
201 diags.push(at(Diag::error(
202 err.code(),
203 format!("in macro `{}`: {err}", macro_.name),
204 )));
205 None
206 }
207 }
208 };
209
210 match ¯o_.body {
211 MacroBody::Expect(items) => {
212 let mut merged: Option<(StepKindId, bool, usize)> = None;
213 for item in items {
214 let status = match &item.status {
215 Some(status) => match resolve_in(status, refs, warnings, diags) {
216 Some(status) => Some(status),
217 None => continue,
218 },
219 None => None,
220 };
221 let fragment = match &item.fragment {
222 Some(fragment) => match resolve_in(fragment, refs, warnings, diags) {
223 Some(fragment) => Some(fragment),
224 None => continue,
225 },
226 None => None,
227 };
228 if let Some((kind, optional, lines)) =
229 merge_expect(status.as_deref(), fragment.as_deref(), out, diags, at)
230 {
231 let entry = merged.get_or_insert((kind, optional, 0));
232 entry.2 += lines;
233 }
234 }
235 if let Some((kind, optional, lines)) = merged {
239 out.push(LoweredStep {
240 step: step_ref.clone(),
241 kind,
242 payload: StepPayload::MergedAsserts { lines },
243 optional,
244 when: None,
245 label: None,
246 save_as: std::collections::BTreeMap::new(),
247 });
248 }
249 }
250 MacroBody::Steps(steps) => {
251 for macro_step in steps {
252 expand_step(
253 macro_step,
254 step_ref,
255 ctx,
256 depth,
257 out,
258 refs,
259 warnings,
260 diags,
261 at,
262 &resolve_in,
263 );
264 }
265 }
266 }
267}
268
269#[allow(clippy::too_many_arguments)]
271fn expand_step(
272 macro_step: &MacroStep,
273 step_ref: &StepRef,
274 ctx: &LowerCtx<'_>,
275 depth: usize,
276 out: &mut Vec<LoweredStep>,
277 refs: &mut Refs,
278 warnings: &mut Vec<Diag>,
279 diags: &mut Vec<Diag>,
280 at: &impl Fn(Diag) -> Diag,
281 resolve_in: &impl Fn(&str, &mut Refs, &mut Vec<Diag>, &mut Vec<Diag>) -> Option<String>,
282) {
283 match ¯o_step.kind {
284 MacroStepKind::Use { target, with } => {
285 let Some(target_macro) = ctx.packs.find_use_target(target) else {
286 return; };
288 let mut child_args = BTreeMap::new();
291 for (key, value) in with {
292 if let Some(resolved) = resolve_in(value, refs, warnings, diags) {
293 child_args.insert(key.clone(), resolved);
294 }
295 }
296 expand_macro(
297 target_macro,
298 &child_args,
299 step_ref,
300 ctx,
301 depth + 1,
302 out,
303 refs,
304 warnings,
305 diags,
306 at,
307 );
308 }
309 MacroStepKind::Payload { kind, payload } => {
310 let label_fakes_start = refs.fakes;
317 let payload = match payload {
318 PayloadForm::Raw(text) => {
319 let Some(resolved) = resolve_in(text, refs, warnings, diags) else {
320 return;
321 };
322 let resolved = if macro_step.retry.is_some() || macro_step.delay_ms.is_some() {
326 bake_entry_options(&resolved, macro_step.retry, macro_step.delay_ms)
327 } else {
328 resolved
329 };
330 StepPayload::HurlEntries(resolved)
331 }
332 PayloadForm::Structured(value) => {
333 let mut resolve = |text: &str| {
337 if !text.contains('$') {
340 return Some(text.to_owned());
341 }
342 resolve_in(text, refs, warnings, diags)
343 };
344 match resolve_structured(value, &mut resolve) {
345 Some(resolved) => StepPayload::Structured(resolved),
346 None => return,
347 }
348 }
349 };
350 let when = match ¯o_step.when {
351 Some(guard) => match resolve_in(guard, refs, warnings, diags) {
352 Some(resolved) => Some(Guard(resolved)),
353 None => return,
354 },
355 None => None,
356 };
357 let functional_fakes_end = refs.fakes;
379 let label = match ¯o_step.name {
380 Some(name) => {
381 refs.fakes = label_fakes_start;
382 let resolved = resolve_in(name, refs, warnings, diags);
383 refs.fakes = functional_fakes_end.max(refs.fakes);
384 match resolved {
385 Some(resolved) => Some(resolved),
386 None => return,
387 }
388 }
389 None => None,
390 };
391 out.push(LoweredStep {
392 step: step_ref.clone(),
393 kind: StepKindId::from(kind.as_str()),
394 payload,
395 optional: macro_step.optional,
396 when,
397 label,
398 save_as: macro_step.save_as.clone(),
399 });
400 }
401 }
402}
403
404fn resolve_structured(
407 value: &serde_json::Value,
408 resolve: &mut dyn FnMut(&str) -> Option<String>,
409) -> Option<serde_json::Value> {
410 use serde_json::Value as J;
411 Some(match value {
412 J::String(text) => J::String(resolve(text)?),
413 J::Array(items) => J::Array(
414 items
415 .iter()
416 .map(|item| resolve_structured(item, resolve))
417 .collect::<Option<_>>()?,
418 ),
419 J::Object(map) => {
420 let mut out = serde_json::Map::new();
421 for (key, item) in map {
422 out.insert(key.clone(), resolve_structured(item, resolve)?);
423 }
424 J::Object(out)
425 }
426 other => other.clone(),
427 })
428}
429
430fn bake_entry_options(
437 text: &str,
438 retry: Option<crate::step::Retry>,
439 delay_ms: Option<u64>,
440) -> String {
441 let mut option_lines: Vec<String> = Vec::new();
442 if let Some(retry) = retry {
443 option_lines.push(format!("retry: {}", retry.count));
444 option_lines.push(format!("retry-interval: {}ms", retry.interval_ms));
445 }
446 if let Some(delay_ms) = delay_ms {
447 option_lines.push(format!("delay: {delay_ms}ms"));
448 }
449 let retry_lines = option_lines.join("\n");
450 let mut author_options = vec![false];
455 let mut in_fence = false;
456 for line in text.lines() {
457 let trimmed = line.trim();
458 if trimmed.starts_with("```") {
459 in_fence = !in_fence;
460 continue;
461 }
462 if in_fence {
463 continue;
464 }
465 if is_method_line(trimmed) {
466 author_options.push(false);
467 } else if trimmed == "[Options]"
468 && let Some(last) = author_options.last_mut()
469 {
470 *last = true;
471 }
472 }
473 let has_author_options = |entry: usize| author_options.get(entry).copied().unwrap_or(false);
474 let mut out: Vec<String> = Vec::new();
475 let mut in_entry_head = false; let mut injected_current = false;
477 let mut in_fence = false; let mut entry = 0usize;
479 for line in text.lines() {
480 let trimmed = line.trim();
481 if trimmed.starts_with("```") {
482 if !in_fence && in_entry_head && !injected_current && !has_author_options(entry) {
485 out.push("[Options]".to_owned());
486 out.push(retry_lines.clone());
487 injected_current = true;
488 }
489 in_fence = !in_fence;
490 in_entry_head = false;
491 out.push(line.to_owned());
492 continue;
493 }
494 if in_fence {
495 out.push(line.to_owned());
496 continue;
497 }
498 if is_method_line(trimmed) {
499 in_entry_head = true;
500 injected_current = false;
501 entry += 1;
502 out.push(line.to_owned());
503 continue;
504 }
505 if trimmed == "[Options]" {
506 out.push(line.to_owned());
509 if !injected_current {
510 out.push(retry_lines.clone());
511 injected_current = true;
512 }
513 in_entry_head = false;
514 continue;
515 }
516 let is_header = in_entry_head && is_header_line(trimmed);
517 if in_entry_head && !is_header && !injected_current && !has_author_options(entry) {
518 out.push("[Options]".to_owned());
519 out.push(retry_lines.clone());
520 injected_current = true;
521 in_entry_head = false;
522 }
523 out.push(line.to_owned());
524 }
525 if in_entry_head && !injected_current {
526 out.push("[Options]".to_owned());
527 out.push(retry_lines.clone());
528 }
529 let mut result = out.join("\n");
530 if text.ends_with('\n') {
531 result.push('\n');
532 }
533 result
534}
535
536fn merge_expect(
543 status: Option<&str>,
544 fragment: Option<&str>,
545 out: &mut [LoweredStep],
546 diags: &mut Vec<Diag>,
547 at: &impl Fn(Diag) -> Diag,
548) -> Option<(StepKindId, bool, usize)> {
549 let Some(previous) = out
550 .iter_mut()
551 .rev()
552 .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
553 else {
554 diags.push(
555 at(Diag::error(
556 "proef::lower::then_before_when",
557 "this assert-only step has no previous request entry to attach to",
558 ))
559 .with_help("a Then step asserts on the request made by an earlier When step"),
560 );
561 return None;
562 };
563
564 if let Some(status) = status
565 && (!status.chars().all(|c| c.is_ascii_digit()) || status.is_empty())
566 {
567 diags.push(at(Diag::error(
568 "proef::lower::bad_status",
569 format!("expected an HTTP status number, got `{status}`"),
570 )));
571 return None;
572 }
573
574 let host_kind = previous.kind.clone();
575 let host_optional = previous.optional;
576 let StepPayload::HurlEntries(text) = &mut previous.payload else {
577 return None;
578 };
579 let (tail_has_http, tail_has_asserts) = last_entry_scan(text);
585 if !tail_has_http {
586 push_line(text, "HTTP *");
587 }
588 if !tail_has_asserts {
589 push_line(text, "[Asserts]");
590 }
591 let mut appended = 0usize;
592 if let Some(status) = status {
593 push_line(text, &format!("status == {status}"));
594 appended += 1;
595 }
596 if let Some(fragment) = fragment {
597 for line in fragment.lines().filter(|l| !l.trim().is_empty()) {
598 push_line(text, line.trim_end());
599 appended += 1;
600 }
601 }
602 Some((host_kind, host_optional, appended))
603}
604
605fn is_header_line(trimmed: &str) -> bool {
610 let Some((name, _)) = trimmed.split_once(':') else {
611 return false;
612 };
613 !name.is_empty()
614 && name != "HTTP"
615 && name
616 .chars()
617 .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
618}
619
620pub(crate) fn is_method_line(trimmed: &str) -> bool {
627 trimmed.split_whitespace().next().is_some_and(|word| {
628 word.len() >= 3
629 && word.chars().all(|c| c.is_ascii_uppercase() || c == '-')
630 && word != "HTTP"
631 }) && trimmed.split_whitespace().count() >= 2
632}
633
634fn last_entry_scan(text: &str) -> (bool, bool) {
639 let mut in_fence = false;
640 let (mut has_http, mut has_asserts) = (false, false);
641 for line in text.lines() {
642 let trimmed = line.trim();
643 if trimmed.starts_with("```") {
644 in_fence = !in_fence;
645 continue;
646 }
647 if in_fence {
648 continue;
649 }
650 if is_method_line(trimmed) {
651 (has_http, has_asserts) = (false, false);
652 continue;
653 }
654 has_http = has_http || trimmed.starts_with("HTTP");
655 has_asserts = has_asserts || trimmed == "[Asserts]";
656 }
657 (has_http, has_asserts)
658}
659
660fn push_line(text: &mut String, line: &str) {
661 if !text.is_empty() && !text.ends_with('\n') {
662 text.push('\n');
663 }
664 text.push_str(line);
665 text.push('\n');
666}
667
668fn segment(steps: Vec<LoweredStep>, kind_to_engine: &BTreeMap<String, String>) -> Vec<StepBatch> {
672 let mut batches: Vec<StepBatch> = Vec::new();
673 for step in steps {
674 let engine = kind_to_engine
678 .get(step.kind.as_str())
679 .map_or_else(|| step.kind.as_str().to_owned(), Clone::clone);
680 let glued = matches!(step.payload, StepPayload::MergedAsserts { .. });
683 let start_new = match batches.last() {
684 None => true,
685 Some(last) => {
686 !glued
687 && (last.engine.as_str() != engine
688 || step.optional
689 || last.steps.last().is_some_and(|s| s.optional))
690 }
691 };
692 if start_new {
693 batches.push(StepBatch {
694 index: batches.len(),
695 engine: crate::engine::EngineId::from(engine.as_str()),
696 steps: vec![step],
697 });
698 } else if let Some(last) = batches.last_mut() {
699 last.steps.push(step);
700 }
701 }
702 batches
703}
704
705fn push_warnings(warnings: &mut Vec<Diag>, texts: &[String], ctx: &LowerCtx<'_>, where_: &str) {
706 for text in texts {
707 warnings.push(
708 Diag::warning("proef::lower::dry_run_unknown", format!("{where_}: {text}"))
709 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
710 );
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 #![allow(clippy::unwrap_used)]
717
718 use super::*;
719 use crate::engine::StepKindSpec;
720 use crate::pack::{self, PackSource};
721 use crate::step::StepPayload;
722
723 const KINDS: &[StepKindSpec] = &[StepKindSpec {
724 prefix: "hurl",
725 schema: "true",
726 validate: None,
727 }];
728
729 const PACK: &str = r#"macros:
730 auth:
731 params: [token]
732 steps:
733 - name: authenticate
734 hurl: |
735 POST ${url:base}/auth
736 Authorization: Bearer ${token}
737 HTTP 200
738 search:
739 params: [term]
740 match: "I search for {term}"
741 steps:
742 - use: auth
743 with: { token: "${secret:apiToken}" }
744 - name: run the search
745 hurl: |
746 GET ${url:base}/search?q=${term}
747 HTTP 200
748 [Captures]
749 recordId: jsonpath "$[0].id"
750 checkHealth:
751 match: the service is healthy
752 steps:
753 - optional: true
754 hurl: |
755 GET ${url:base}/health
756 expectStatus:
757 params: [status]
758 match: "the response status is {status}"
759 expect:
760 - status: "${status}"
761"#;
762
763 fn fixture() -> (
764 crate::feature::FeatureFile,
765 crate::bind::BoundScenario,
766 PackSet,
767 ) {
768 let packs = pack::load(
769 &[PackSource {
770 name: "test.yaml".into(),
771 text: Arc::from(PACK),
772 }],
773 KINDS,
774 )
775 .unwrap();
776 let feature = crate::feature::parse(
777 "t.feature",
778 "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",
779 )
780 .unwrap();
781 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
782 (feature, scenario, packs)
783 }
784
785 fn ctx<'a>(
786 feature: &'a crate::feature::FeatureFile,
787 packs: &'a PackSet,
788 kind_to_engine: &'a BTreeMap<String, String>,
789 env: &'a BTreeMap<String, String>,
790 config_vars: &'a BTreeMap<String, String>,
791 world: &'a World,
792 ) -> LowerCtx<'a> {
793 LowerCtx {
794 feature,
795 packs,
796 kind_to_engine,
797 env,
798 config_vars,
799 run_id: "run-0001",
800 world,
801 mode: ResolveMode::DryRun,
802 }
803 }
804
805 #[test]
806 fn expansion_resolution_merge_and_segmentation_work_together() {
807 let (feature, scenario, packs) = fixture();
808 let kind_to_engine: BTreeMap<String, String> =
809 [("hurl".to_owned(), "hurl".to_owned())].into();
810 let env = BTreeMap::new();
811 let config_vars =
812 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
813 let world = World::default();
814 let lowered = lower(
815 &scenario,
816 &ctx(
817 &feature,
818 &packs,
819 &kind_to_engine,
820 &env,
821 &config_vars,
822 &world,
823 ),
824 )
825 .unwrap();
826
827 assert_eq!(lowered.batches.len(), 2);
831 assert_eq!(lowered.batches[0].steps.len(), 1);
832 assert!(lowered.batches[0].steps[0].optional);
833 assert_eq!(lowered.batches[1].steps.len(), 3);
834 let StepPayload::MergedAsserts { lines } = lowered.batches[1].steps[2].payload else {
835 panic!("expected a merged-asserts step for the Then line");
836 };
837 assert_eq!(lines, 1, "the expect appended exactly `status == 200`");
838
839 let StepPayload::HurlEntries(auth) = &lowered.batches[1].steps[0].payload else {
841 panic!("expected hurl entries");
842 };
843 assert!(auth.contains("POST http://fixture.local/auth"), "{auth}");
844 assert!(
845 auth.contains("Bearer {{apiToken}}"),
846 "secret placeholder: {auth}"
847 );
848 assert!(lowered.secrets.contains("apiToken"));
849
850 let StepPayload::HurlEntries(search) = &lowered.batches[1].steps[1].payload else {
852 panic!("expected hurl entries");
853 };
854 assert!(
855 search.contains("GET http://fixture.local/search?q=Jansen"),
856 "{search}"
857 );
858 assert!(search.contains("[Asserts]"), "{search}");
859 assert!(search.trim_end().ends_with("status == 200"), "{search}");
860
861 assert_eq!(lowered.batches[1].steps[1].step.line, 4);
863 assert_eq!(
864 lowered.batches[1].steps[0].label.as_deref(),
865 Some("authenticate")
866 );
867 }
868
869 #[test]
870 fn then_before_when_is_an_error() {
871 let (_, _, packs) = fixture();
872 let feature = crate::feature::parse(
873 "t.feature",
874 "Feature: F\n Scenario: S\n Then the response status is 200\n",
875 )
876 .unwrap();
877 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
878 let kind_to_engine = BTreeMap::new();
879 let env = BTreeMap::new();
880 let config_vars = BTreeMap::new();
881 let world = World::default();
882 let errs = lower(
883 &scenario,
884 &ctx(
885 &feature,
886 &packs,
887 &kind_to_engine,
888 &env,
889 &config_vars,
890 &world,
891 ),
892 )
893 .unwrap_err();
894 assert_eq!(errs[0].code, "proef::lower::then_before_when");
895 }
896
897 #[test]
907 fn an_expect_fragment_that_resolves_empty_does_not_invert_the_merged_span() {
908 const PACK: &str = r#"macros:
909 ping:
910 match: the service is pinged
911 steps:
912 - hurl: |
913 GET ${url:base}/ping
914 HTTP 200
915 expectBlank:
916 match: nothing extra is asserted
917 expect:
918 - hurl: "${vars:blank}"
919"#;
920 let packs = pack::load(
921 &[PackSource {
922 name: "test.yaml".into(),
923 text: Arc::from(PACK),
924 }],
925 KINDS,
926 )
927 .unwrap();
928 let feature = crate::feature::parse(
929 "t.feature",
930 "Feature: F\n Scenario: S\n Given the service is pinged\n Then nothing extra is asserted\n",
931 )
932 .unwrap();
933 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
934 let kind_to_engine: BTreeMap<String, String> =
935 [("hurl".to_owned(), "hurl".to_owned())].into();
936 let env = BTreeMap::new();
937 let config_vars = BTreeMap::from([
938 ("url:base".to_owned(), "http://fixture.local".to_owned()),
939 ("vars:blank".to_owned(), String::new()),
940 ]);
941 let world = World::default();
942 let lowered = lower(
943 &scenario,
944 &ctx(
945 &feature,
946 &packs,
947 &kind_to_engine,
948 &env,
949 &config_vars,
950 &world,
951 ),
952 )
953 .unwrap();
954
955 assert_eq!(lowered.batches[0].steps.len(), 2);
956 let StepPayload::MergedAsserts { lines } = lowered.batches[0].steps[1].payload else {
957 panic!("expected a merged-asserts step for the Then line");
958 };
959 assert_eq!(lines, 0, "the fragment resolved to nothing");
960
961 let artifact = crate::emit::emit(&lowered, "t", &world).unwrap();
962 for entry in &artifact.map.entries {
963 let [start, end] = entry.hurl_lines;
964 assert!(
965 start <= end,
966 "inverted span for a zero-line merge: {start}..{end}"
967 );
968 }
969 }
970
971 #[test]
974 fn structured_payloads_resolve_placeholders_recursively() {
975 const ALT_KINDS: &[StepKindSpec] = &[StepKindSpec {
976 prefix: "alt",
977 schema: "true",
978 validate: None,
979 }];
980 let packs = pack::load(
981 &[PackSource {
982 name: "alt.yaml".into(),
983 text: Arc::from(
984 "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",
985 ),
986 }],
987 ALT_KINDS,
988 )
989 .unwrap();
990 let feature = crate::feature::parse(
991 "t.feature",
992 "Feature: F\n Scenario: S\n When the alternate step runs\n",
993 )
994 .unwrap();
995 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
996 let kind_to_engine: BTreeMap<String, String> =
997 [("alt".to_owned(), "alt".to_owned())].into();
998 let env = BTreeMap::new();
999 let config_vars =
1000 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1001 let world = World::default();
1002 let lowered = lower(
1003 &scenario,
1004 &ctx(
1005 &feature,
1006 &packs,
1007 &kind_to_engine,
1008 &env,
1009 &config_vars,
1010 &world,
1011 ),
1012 )
1013 .unwrap();
1014 let StepPayload::Structured(value) = &lowered.batches[0].steps[0].payload else {
1015 panic!("structured payload expected");
1016 };
1017 assert_eq!(value["target"], "http://fixture.local/item");
1018 assert_eq!(value["checks"][0], "http://fixture.local");
1019 assert_eq!(value["checks"][1], 7);
1020 }
1021
1022 #[test]
1026 fn expect_merge_scopes_to_the_last_entry() {
1027 let packs = pack::load(
1028 &[PackSource {
1029 name: "multi.yaml".into(),
1030 text: Arc::from(
1031 "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",
1032 ),
1033 }],
1034 KINDS,
1035 )
1036 .unwrap();
1037 let feature = crate::feature::parse(
1038 "t.feature",
1039 "Feature: F\n Scenario: S\n When both calls run\n Then the response status is 201\n",
1040 )
1041 .unwrap();
1042 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1043 let kind_to_engine: BTreeMap<String, String> =
1044 [("hurl".to_owned(), "hurl".to_owned())].into();
1045 let env = BTreeMap::new();
1046 let config_vars = BTreeMap::new();
1047 let world = World::default();
1048 let lowered = lower(
1049 &scenario,
1050 &ctx(
1051 &feature,
1052 &packs,
1053 &kind_to_engine,
1054 &env,
1055 &config_vars,
1056 &world,
1057 ),
1058 )
1059 .unwrap();
1060 let StepPayload::HurlEntries(text) = &lowered.batches[0].steps[0].payload else {
1061 panic!("expected hurl entries");
1062 };
1063 let tail = text.split("GET http://x/b").nth(1).unwrap();
1067 assert!(tail.contains("HTTP *"), "{text}");
1068 assert!(tail.contains("[Asserts]"), "{text}");
1069 assert!(tail.contains("status == 201"), "{text}");
1070 }
1071
1072 #[test]
1076 fn baked_options_extend_a_late_author_options_section() {
1077 let retry = Some(crate::step::Retry {
1078 count: 2,
1079 interval_ms: 100,
1080 });
1081 let body =
1082 "GET http://x/a\n[QueryStringParams]\nq: 1\n[Options]\nverbose: true\nHTTP 200\n";
1083 let baked = bake_entry_options(body, retry, None);
1084 assert_eq!(baked.matches("[Options]").count(), 1, "{baked}");
1085 assert!(
1086 baked.contains("[Options]\nretry: 2\nretry-interval: 100ms\nverbose: true"),
1087 "{baked}"
1088 );
1089 }
1090
1091 #[test]
1094 fn baked_options_never_enter_bodies() {
1095 let retry = Some(crate::step::Retry {
1096 count: 2,
1097 interval_ms: 100,
1098 });
1099 for body in [
1100 "POST http://x/a\n```\nNOTE FOR REVIEW\nsecond line\n```\nHTTP 200\n",
1101 "POST http://x/a\n<root xmlns:x=\"urn:example\">\n <child>hi</child>\n</root>\nHTTP 200\n",
1102 "POST http://x/a\n{\"note\": \"FOR REVIEW\"}\nHTTP 200\n",
1103 ] {
1104 let baked = bake_entry_options(body, retry, None);
1105 assert_eq!(
1106 baked.matches("[Options]").count(),
1107 1,
1108 "exactly one options block in:\n{baked}"
1109 );
1110 let options_at = baked.find("[Options]").unwrap_or(usize::MAX);
1111 let body_at = baked
1112 .find("```")
1113 .or_else(|| baked.find('<'))
1114 .or_else(|| baked.find('{'))
1115 .unwrap_or(0);
1116 assert!(options_at < body_at, "options precede the body:\n{baked}");
1117 }
1118 }
1119
1120 #[test]
1121 fn engine_change_splits_batches() {
1122 let steps: Vec<LoweredStep> = ["hurl", "hurl", "alt", "hurl"]
1123 .iter()
1124 .map(|kind| LoweredStep {
1125 step: StepRef {
1126 file: Arc::from("f"),
1127 line: 1,
1128 text: Arc::from("t"),
1129 },
1130 kind: StepKindId::from(*kind),
1131 payload: StepPayload::HurlEntries(String::new()),
1132 optional: false,
1133 when: None,
1134 label: None,
1135 save_as: BTreeMap::new(),
1136 })
1137 .collect();
1138 let mapping: BTreeMap<String, String> = [
1139 ("hurl".to_owned(), "hurl".to_owned()),
1140 ("alt".to_owned(), "alt".to_owned()),
1141 ]
1142 .into();
1143 let batches = segment(steps, &mapping);
1144 let sizes: Vec<usize> = batches.iter().map(|b| b.steps.len()).collect();
1145 assert_eq!(sizes, vec![2, 1, 1]);
1146 assert_eq!(batches[1].engine.as_str(), "alt");
1147 let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
1149 assert_eq!(indexes, vec![0, 1, 2]);
1150 }
1151
1152 #[test]
1159 fn label_mirrors_the_payloads_fake_values_without_shifting_later_steps() {
1160 const FAKE_PACK: &str = r#"macros:
1161 searchFor:
1162 params: [term]
1163 match: "the operator searches for {term}"
1164 steps:
1165 - name: "search for ${term}"
1166 hurl: |
1167 GET ${url:base}/search
1168 [Query]
1169 q: ${term}
1170 HTTP 200
1171 pingFake:
1172 match: a fresh fake is requested
1173 steps:
1174 - hurl: |
1175 GET ${url:base}/ping
1176 [Query]
1177 v: ${fake:lastName}
1178 HTTP 200
1179"#;
1180 let packs = pack::load(
1181 &[PackSource {
1182 name: "fakes.yaml".into(),
1183 text: Arc::from(FAKE_PACK),
1184 }],
1185 KINDS,
1186 )
1187 .unwrap();
1188 let feature = crate::feature::parse(
1189 "t.feature",
1190 "Feature: F\n Scenario: S\n When the operator searches for ${fake:lastName}\n Then a fresh fake is requested\n",
1191 )
1192 .unwrap();
1193 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1194 let kind_to_engine: BTreeMap<String, String> =
1195 [("hurl".to_owned(), "hurl".to_owned())].into();
1196 let env = BTreeMap::new();
1197 let config_vars =
1198 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1199 let world = World::default();
1200 let lowered = lower(
1201 &scenario,
1202 &ctx(
1203 &feature,
1204 &packs,
1205 &kind_to_engine,
1206 &env,
1207 &config_vars,
1208 &world,
1209 ),
1210 )
1211 .unwrap();
1212
1213 assert_eq!(lowered.batches[0].steps.len(), 2);
1215 let StepPayload::HurlEntries(search) = &lowered.batches[0].steps[0].payload else {
1216 panic!("expected hurl entries");
1217 };
1218 let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1219
1220 let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1223 assert!(
1224 search.contains(&format!("q: {occurrence_0}")),
1225 "payload: {search}"
1226 );
1227 assert!(label.contains(&occurrence_0), "label: {label}");
1228
1229 let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1232 panic!("expected hurl entries");
1233 };
1234 let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1235 assert!(ping.contains(&format!("v: {occurrence_1}")), "ping: {ping}");
1236 }
1237
1238 #[test]
1245 fn label_with_more_fakes_than_its_payload_does_not_leak_occurrences_to_later_steps() {
1246 const FAKE_PACK: &str = r#"macros:
1247 unmirroredLabel:
1248 match: a label mentions more fakes than its payload
1249 steps:
1250 - name: "${fake:lastName} vs ${fake:lastName}"
1251 hurl: |
1252 GET ${url:base}/probe
1253 [Query]
1254 q: ${fake:lastName}
1255 HTTP 200
1256 pingFake:
1257 match: a fresh fake is requested
1258 steps:
1259 - hurl: |
1260 GET ${url:base}/ping
1261 [Query]
1262 v: ${fake:lastName}
1263 HTTP 200
1264"#;
1265 let packs = pack::load(
1266 &[PackSource {
1267 name: "unmirrored.yaml".into(),
1268 text: Arc::from(FAKE_PACK),
1269 }],
1270 KINDS,
1271 )
1272 .unwrap();
1273 let feature = crate::feature::parse(
1274 "t.feature",
1275 "Feature: F\n Scenario: S\n When a label mentions more fakes than its payload\n Then a fresh fake is requested\n",
1276 )
1277 .unwrap();
1278 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
1279 let kind_to_engine: BTreeMap<String, String> =
1280 [("hurl".to_owned(), "hurl".to_owned())].into();
1281 let env = BTreeMap::new();
1282 let config_vars =
1283 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
1284 let world = World::default();
1285 let lowered = lower(
1286 &scenario,
1287 &ctx(
1288 &feature,
1289 &packs,
1290 &kind_to_engine,
1291 &env,
1292 &config_vars,
1293 &world,
1294 ),
1295 )
1296 .unwrap();
1297
1298 assert_eq!(lowered.batches[0].steps.len(), 2);
1300 let label = lowered.batches[0].steps[0].label.as_deref().unwrap();
1301 let StepPayload::HurlEntries(ping) = &lowered.batches[0].steps[1].payload else {
1302 panic!("expected hurl entries");
1303 };
1304
1305 let occurrence_0 = crate::fake::generate("run-0001", 0, "lastName").unwrap();
1309 let occurrence_1 = crate::fake::generate("run-0001", 1, "lastName").unwrap();
1310 assert!(label.contains(&occurrence_0), "label: {label}");
1311 assert!(label.contains(&occurrence_1), "label: {label}");
1312
1313 let occurrence_2 = crate::fake::generate("run-0001", 2, "lastName").unwrap();
1317 assert!(
1318 !ping.contains(&format!("v: {occurrence_1}")),
1319 "the next step's fake reused an occurrence the label already \
1320 displayed: {ping}"
1321 );
1322 assert!(ping.contains(&format!("v: {occurrence_2}")), "ping: {ping}");
1323 }
1324}