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}
73
74pub fn lower(scenario: &BoundScenario, ctx: &LowerCtx<'_>) -> Result<LoweredScenario, Vec<Diag>> {
76 let mut diags: Vec<Diag> = Vec::new();
77 let mut warnings: Vec<Diag> = Vec::new();
78 let mut refs = Refs::default();
79 let mut lowered: Vec<LoweredStep> = Vec::new();
80 for step in &scenario.steps {
81 let step_ref = StepRef {
82 file: Arc::from(ctx.feature.path.as_str()),
83 line: step.defn.line,
84 text: Arc::from(step.defn.text.as_str()),
85 };
86 let at = |diag: Diag| {
87 diag.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source))
88 .with_span(step.defn.span)
89 };
90 let Some(macro_) = ctx.packs.macros.get(&step.macro_name) else {
91 continue; };
93 expand_macro(
94 macro_,
95 &step.args,
96 &step_ref,
97 ctx,
98 0,
99 &mut lowered,
100 &mut refs,
101 &mut warnings,
102 &mut diags,
103 &at,
104 );
105 }
106
107 for step in &lowered {
113 if matches!(step.payload, StepPayload::MergedAsserts { .. }) {
114 continue; }
116 if !ctx.kind_to_engine.contains_key(step.kind.as_str()) {
117 diags.push(
118 Diag::error(
119 "proef::lower::kind_unrouted",
120 format!(
121 "internal: step kind `{}` is not claimed by any registered engine \
122 (registry/pack-validation drift)",
123 step.kind.as_str()
124 ),
125 )
126 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
127 );
128 }
129 }
130
131 if diags.iter().any(|d| d.severity == Severity::Error) {
132 return Err(diags);
133 }
134
135 Ok(LoweredScenario {
136 name: scenario.name.clone(),
137 tags: scenario.tags.clone(),
138 line: scenario.line,
139 batches: segment(lowered, ctx.kind_to_engine),
140 secrets: refs.secrets,
141 globals: refs.globals,
142 warnings,
143 })
144}
145
146#[allow(clippy::too_many_arguments)]
148fn expand_macro(
149 macro_: &Macro,
150 args: &BTreeMap<String, String>,
151 step_ref: &StepRef,
152 ctx: &LowerCtx<'_>,
153 depth: usize,
154 out: &mut Vec<LoweredStep>,
155 refs: &mut Refs,
156 warnings: &mut Vec<Diag>,
157 diags: &mut Vec<Diag>,
158 at: &impl Fn(Diag) -> Diag,
159) {
160 if depth > MAX_EXPANSION_DEPTH {
161 diags.push(at(Diag::error(
162 "proef::lower::expansion_too_deep",
163 format!(
164 "macro expansion exceeded depth {MAX_EXPANSION_DEPTH} at `{}`",
165 macro_.name
166 ),
167 )));
168 return;
169 }
170
171 let resolve_in = |text: &str,
172 refs: &mut Refs,
173 warnings: &mut Vec<Diag>,
174 diags: &mut Vec<Diag>|
175 -> Option<String> {
176 let resolve_ctx = ResolveCtx {
177 args,
178 defaults: ¯o_.defaults,
179 env: ctx.env,
180 config_vars: ctx.config_vars,
181 run_id: ctx.run_id,
182 world: ctx.world,
183 mode: ctx.mode,
184 };
185 match resolve::resolve(text, &resolve_ctx) {
186 Ok(resolution) => {
187 refs.secrets.extend(resolution.secrets);
188 refs.globals.extend(resolution.globals);
189 push_warnings(warnings, &resolution.warnings, ctx, ¯o_.name);
190 Some(resolution.text)
191 }
192 Err(err) => {
193 diags.push(at(Diag::error(
194 err.code(),
195 format!("in macro `{}`: {err}", macro_.name),
196 )));
197 None
198 }
199 }
200 };
201
202 match ¯o_.body {
203 MacroBody::Expect(items) => {
204 let mut merged: Option<(StepKindId, bool, usize)> = None;
205 for item in items {
206 let status = match &item.status {
207 Some(status) => match resolve_in(status, refs, warnings, diags) {
208 Some(status) => Some(status),
209 None => continue,
210 },
211 None => None,
212 };
213 let fragment = match &item.fragment {
214 Some(fragment) => match resolve_in(fragment, refs, warnings, diags) {
215 Some(fragment) => Some(fragment),
216 None => continue,
217 },
218 None => None,
219 };
220 if let Some((kind, optional, lines)) =
221 merge_expect(status.as_deref(), fragment.as_deref(), out, diags, at)
222 {
223 let entry = merged.get_or_insert((kind, optional, 0));
224 entry.2 += lines;
225 }
226 }
227 if let Some((kind, optional, lines)) = merged {
231 out.push(LoweredStep {
232 step: step_ref.clone(),
233 kind,
234 payload: StepPayload::MergedAsserts { lines },
235 optional,
236 when: None,
237 label: None,
238 save_as: std::collections::BTreeMap::new(),
239 });
240 }
241 }
242 MacroBody::Steps(steps) => {
243 for macro_step in steps {
244 expand_step(
245 macro_step,
246 step_ref,
247 ctx,
248 depth,
249 out,
250 refs,
251 warnings,
252 diags,
253 at,
254 &resolve_in,
255 );
256 }
257 }
258 }
259}
260
261#[allow(clippy::too_many_arguments)]
263fn expand_step(
264 macro_step: &MacroStep,
265 step_ref: &StepRef,
266 ctx: &LowerCtx<'_>,
267 depth: usize,
268 out: &mut Vec<LoweredStep>,
269 refs: &mut Refs,
270 warnings: &mut Vec<Diag>,
271 diags: &mut Vec<Diag>,
272 at: &impl Fn(Diag) -> Diag,
273 resolve_in: &impl Fn(&str, &mut Refs, &mut Vec<Diag>, &mut Vec<Diag>) -> Option<String>,
274) {
275 match ¯o_step.kind {
276 MacroStepKind::Use { target, with } => {
277 let Some(target_macro) = ctx.packs.find_use_target(target) else {
278 return; };
280 let mut child_args = BTreeMap::new();
283 for (key, value) in with {
284 if let Some(resolved) = resolve_in(value, refs, warnings, diags) {
285 child_args.insert(key.clone(), resolved);
286 }
287 }
288 expand_macro(
289 target_macro,
290 &child_args,
291 step_ref,
292 ctx,
293 depth + 1,
294 out,
295 refs,
296 warnings,
297 diags,
298 at,
299 );
300 }
301 MacroStepKind::Payload { kind, payload } => {
302 let payload = match payload {
303 PayloadForm::Raw(text) => {
304 let Some(resolved) = resolve_in(text, refs, warnings, diags) else {
305 return;
306 };
307 let resolved = if macro_step.retry.is_some() || macro_step.delay_ms.is_some() {
311 bake_entry_options(&resolved, macro_step.retry, macro_step.delay_ms)
312 } else {
313 resolved
314 };
315 StepPayload::HurlEntries(resolved)
316 }
317 PayloadForm::Structured(value) => {
318 let mut resolve = |text: &str| {
322 if !text.contains('$') {
325 return Some(text.to_owned());
326 }
327 resolve_in(text, refs, warnings, diags)
328 };
329 match resolve_structured(value, &mut resolve) {
330 Some(resolved) => StepPayload::Structured(resolved),
331 None => return,
332 }
333 }
334 };
335 let when = match ¯o_step.when {
336 Some(guard) => match resolve_in(guard, refs, warnings, diags) {
337 Some(resolved) => Some(Guard(resolved)),
338 None => return,
339 },
340 None => None,
341 };
342 let label = match ¯o_step.name {
345 Some(name) => match resolve_in(name, refs, warnings, diags) {
346 Some(resolved) => Some(resolved),
347 None => return,
348 },
349 None => None,
350 };
351 out.push(LoweredStep {
352 step: step_ref.clone(),
353 kind: StepKindId::from(kind.as_str()),
354 payload,
355 optional: macro_step.optional,
356 when,
357 label,
358 save_as: macro_step.save_as.clone(),
359 });
360 }
361 }
362}
363
364fn resolve_structured(
367 value: &serde_json::Value,
368 resolve: &mut dyn FnMut(&str) -> Option<String>,
369) -> Option<serde_json::Value> {
370 use serde_json::Value as J;
371 Some(match value {
372 J::String(text) => J::String(resolve(text)?),
373 J::Array(items) => J::Array(
374 items
375 .iter()
376 .map(|item| resolve_structured(item, resolve))
377 .collect::<Option<_>>()?,
378 ),
379 J::Object(map) => {
380 let mut out = serde_json::Map::new();
381 for (key, item) in map {
382 out.insert(key.clone(), resolve_structured(item, resolve)?);
383 }
384 J::Object(out)
385 }
386 other => other.clone(),
387 })
388}
389
390fn bake_entry_options(
397 text: &str,
398 retry: Option<crate::step::Retry>,
399 delay_ms: Option<u64>,
400) -> String {
401 let mut option_lines: Vec<String> = Vec::new();
402 if let Some(retry) = retry {
403 option_lines.push(format!("retry: {}", retry.count));
404 option_lines.push(format!("retry-interval: {}ms", retry.interval_ms));
405 }
406 if let Some(delay_ms) = delay_ms {
407 option_lines.push(format!("delay: {delay_ms}ms"));
408 }
409 let retry_lines = option_lines.join("\n");
410 let mut author_options = vec![false];
415 let mut in_fence = false;
416 for line in text.lines() {
417 let trimmed = line.trim();
418 if trimmed.starts_with("```") {
419 in_fence = !in_fence;
420 continue;
421 }
422 if in_fence {
423 continue;
424 }
425 if is_method_line(trimmed) {
426 author_options.push(false);
427 } else if trimmed == "[Options]"
428 && let Some(last) = author_options.last_mut()
429 {
430 *last = true;
431 }
432 }
433 let has_author_options = |entry: usize| author_options.get(entry).copied().unwrap_or(false);
434 let mut out: Vec<String> = Vec::new();
435 let mut in_entry_head = false; let mut injected_current = false;
437 let mut in_fence = false; let mut entry = 0usize;
439 for line in text.lines() {
440 let trimmed = line.trim();
441 if trimmed.starts_with("```") {
442 if !in_fence && in_entry_head && !injected_current && !has_author_options(entry) {
445 out.push("[Options]".to_owned());
446 out.push(retry_lines.clone());
447 injected_current = true;
448 }
449 in_fence = !in_fence;
450 in_entry_head = false;
451 out.push(line.to_owned());
452 continue;
453 }
454 if in_fence {
455 out.push(line.to_owned());
456 continue;
457 }
458 if is_method_line(trimmed) {
459 in_entry_head = true;
460 injected_current = false;
461 entry += 1;
462 out.push(line.to_owned());
463 continue;
464 }
465 if trimmed == "[Options]" {
466 out.push(line.to_owned());
469 if !injected_current {
470 out.push(retry_lines.clone());
471 injected_current = true;
472 }
473 in_entry_head = false;
474 continue;
475 }
476 let is_header = in_entry_head && is_header_line(trimmed);
477 if in_entry_head && !is_header && !injected_current && !has_author_options(entry) {
478 out.push("[Options]".to_owned());
479 out.push(retry_lines.clone());
480 injected_current = true;
481 in_entry_head = false;
482 }
483 out.push(line.to_owned());
484 }
485 if in_entry_head && !injected_current {
486 out.push("[Options]".to_owned());
487 out.push(retry_lines.clone());
488 }
489 let mut result = out.join("\n");
490 if text.ends_with('\n') {
491 result.push('\n');
492 }
493 result
494}
495
496fn merge_expect(
503 status: Option<&str>,
504 fragment: Option<&str>,
505 out: &mut [LoweredStep],
506 diags: &mut Vec<Diag>,
507 at: &impl Fn(Diag) -> Diag,
508) -> Option<(StepKindId, bool, usize)> {
509 let Some(previous) = out
510 .iter_mut()
511 .rev()
512 .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
513 else {
514 diags.push(
515 at(Diag::error(
516 "proef::lower::then_before_when",
517 "this assert-only step has no previous request entry to attach to",
518 ))
519 .with_help("a Then step asserts on the request made by an earlier When step"),
520 );
521 return None;
522 };
523
524 if let Some(status) = status
525 && (!status.chars().all(|c| c.is_ascii_digit()) || status.is_empty())
526 {
527 diags.push(at(Diag::error(
528 "proef::lower::bad_status",
529 format!("expected an HTTP status number, got `{status}`"),
530 )));
531 return None;
532 }
533
534 let host_kind = previous.kind.clone();
535 let host_optional = previous.optional;
536 let StepPayload::HurlEntries(text) = &mut previous.payload else {
537 return None;
538 };
539 let (tail_has_http, tail_has_asserts) = last_entry_scan(text);
545 if !tail_has_http {
546 push_line(text, "HTTP *");
547 }
548 if !tail_has_asserts {
549 push_line(text, "[Asserts]");
550 }
551 let mut appended = 0usize;
552 if let Some(status) = status {
553 push_line(text, &format!("status == {status}"));
554 appended += 1;
555 }
556 if let Some(fragment) = fragment {
557 for line in fragment.lines().filter(|l| !l.trim().is_empty()) {
558 push_line(text, line.trim_end());
559 appended += 1;
560 }
561 }
562 Some((host_kind, host_optional, appended))
563}
564
565fn is_header_line(trimmed: &str) -> bool {
570 let Some((name, _)) = trimmed.split_once(':') else {
571 return false;
572 };
573 !name.is_empty()
574 && name != "HTTP"
575 && name
576 .chars()
577 .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
578}
579
580fn is_method_line(trimmed: &str) -> bool {
584 trimmed.split_whitespace().next().is_some_and(|word| {
585 word.len() >= 3
586 && word.chars().all(|c| c.is_ascii_uppercase() || c == '-')
587 && word != "HTTP"
588 }) && trimmed.split_whitespace().count() >= 2
589}
590
591fn last_entry_scan(text: &str) -> (bool, bool) {
596 let mut in_fence = false;
597 let (mut has_http, mut has_asserts) = (false, false);
598 for line in text.lines() {
599 let trimmed = line.trim();
600 if trimmed.starts_with("```") {
601 in_fence = !in_fence;
602 continue;
603 }
604 if in_fence {
605 continue;
606 }
607 if is_method_line(trimmed) {
608 (has_http, has_asserts) = (false, false);
609 continue;
610 }
611 has_http = has_http || trimmed.starts_with("HTTP");
612 has_asserts = has_asserts || trimmed == "[Asserts]";
613 }
614 (has_http, has_asserts)
615}
616
617fn push_line(text: &mut String, line: &str) {
618 if !text.is_empty() && !text.ends_with('\n') {
619 text.push('\n');
620 }
621 text.push_str(line);
622 text.push('\n');
623}
624
625fn segment(steps: Vec<LoweredStep>, kind_to_engine: &BTreeMap<String, String>) -> Vec<StepBatch> {
629 let mut batches: Vec<StepBatch> = Vec::new();
630 for step in steps {
631 let engine = kind_to_engine
635 .get(step.kind.as_str())
636 .map_or_else(|| step.kind.as_str().to_owned(), Clone::clone);
637 let glued = matches!(step.payload, StepPayload::MergedAsserts { .. });
640 let start_new = match batches.last() {
641 None => true,
642 Some(last) => {
643 !glued
644 && (last.engine.as_str() != engine
645 || step.optional
646 || last.steps.last().is_some_and(|s| s.optional))
647 }
648 };
649 if start_new {
650 batches.push(StepBatch {
651 index: batches.len(),
652 engine: crate::engine::EngineId::from(engine.as_str()),
653 steps: vec![step],
654 });
655 } else if let Some(last) = batches.last_mut() {
656 last.steps.push(step);
657 }
658 }
659 batches
660}
661
662fn push_warnings(warnings: &mut Vec<Diag>, texts: &[String], ctx: &LowerCtx<'_>, where_: &str) {
663 for text in texts {
664 warnings.push(
665 Diag::warning("proef::lower::dry_run_unknown", format!("{where_}: {text}"))
666 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
667 );
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 #![allow(clippy::unwrap_used)]
674
675 use super::*;
676 use crate::engine::StepKindSpec;
677 use crate::pack::{self, PackSource};
678 use crate::step::StepPayload;
679
680 const KINDS: &[StepKindSpec] = &[StepKindSpec {
681 prefix: "hurl",
682 schema: "true",
683 validate: None,
684 }];
685
686 const PACK: &str = r#"macros:
687 auth:
688 params: [token]
689 steps:
690 - name: authenticate
691 hurl: |
692 POST ${url:base}/auth
693 Authorization: Bearer ${token}
694 HTTP 200
695 search:
696 params: [term]
697 match: "I search for {term}"
698 steps:
699 - use: auth
700 with: { token: "${secret:apiToken}" }
701 - name: run the search
702 hurl: |
703 GET ${url:base}/search?q=${term}
704 HTTP 200
705 [Captures]
706 recordId: jsonpath "$[0].id"
707 checkHealth:
708 match: the service is healthy
709 steps:
710 - optional: true
711 hurl: |
712 GET ${url:base}/health
713 expectStatus:
714 params: [status]
715 match: "the response status is {status}"
716 expect:
717 - status: "${status}"
718"#;
719
720 fn fixture() -> (
721 crate::feature::FeatureFile,
722 crate::bind::BoundScenario,
723 PackSet,
724 ) {
725 let packs = pack::load(
726 &[PackSource {
727 name: "test.yaml".into(),
728 text: Arc::from(PACK),
729 }],
730 KINDS,
731 )
732 .unwrap();
733 let feature = crate::feature::parse(
734 "t.feature",
735 "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",
736 )
737 .unwrap();
738 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
739 (feature, scenario, packs)
740 }
741
742 fn ctx<'a>(
743 feature: &'a crate::feature::FeatureFile,
744 packs: &'a PackSet,
745 kind_to_engine: &'a BTreeMap<String, String>,
746 env: &'a BTreeMap<String, String>,
747 config_vars: &'a BTreeMap<String, String>,
748 world: &'a World,
749 ) -> LowerCtx<'a> {
750 LowerCtx {
751 feature,
752 packs,
753 kind_to_engine,
754 env,
755 config_vars,
756 run_id: "run-0001",
757 world,
758 mode: ResolveMode::DryRun,
759 }
760 }
761
762 #[test]
763 fn expansion_resolution_merge_and_segmentation_work_together() {
764 let (feature, scenario, packs) = fixture();
765 let kind_to_engine: BTreeMap<String, String> =
766 [("hurl".to_owned(), "hurl".to_owned())].into();
767 let env = BTreeMap::new();
768 let config_vars =
769 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
770 let world = World::default();
771 let lowered = lower(
772 &scenario,
773 &ctx(
774 &feature,
775 &packs,
776 &kind_to_engine,
777 &env,
778 &config_vars,
779 &world,
780 ),
781 )
782 .unwrap();
783
784 assert_eq!(lowered.batches.len(), 2);
788 assert_eq!(lowered.batches[0].steps.len(), 1);
789 assert!(lowered.batches[0].steps[0].optional);
790 assert_eq!(lowered.batches[1].steps.len(), 3);
791 let StepPayload::MergedAsserts { lines } = lowered.batches[1].steps[2].payload else {
792 panic!("expected a merged-asserts step for the Then line");
793 };
794 assert_eq!(lines, 1, "the expect appended exactly `status == 200`");
795
796 let StepPayload::HurlEntries(auth) = &lowered.batches[1].steps[0].payload else {
798 panic!("expected hurl entries");
799 };
800 assert!(auth.contains("POST http://fixture.local/auth"), "{auth}");
801 assert!(
802 auth.contains("Bearer {{apiToken}}"),
803 "secret placeholder: {auth}"
804 );
805 assert!(lowered.secrets.contains("apiToken"));
806
807 let StepPayload::HurlEntries(search) = &lowered.batches[1].steps[1].payload else {
809 panic!("expected hurl entries");
810 };
811 assert!(
812 search.contains("GET http://fixture.local/search?q=Jansen"),
813 "{search}"
814 );
815 assert!(search.contains("[Asserts]"), "{search}");
816 assert!(search.trim_end().ends_with("status == 200"), "{search}");
817
818 assert_eq!(lowered.batches[1].steps[1].step.line, 4);
820 assert_eq!(
821 lowered.batches[1].steps[0].label.as_deref(),
822 Some("authenticate")
823 );
824 }
825
826 #[test]
827 fn then_before_when_is_an_error() {
828 let (_, _, packs) = fixture();
829 let feature = crate::feature::parse(
830 "t.feature",
831 "Feature: F\n Scenario: S\n Then the response status is 200\n",
832 )
833 .unwrap();
834 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
835 let kind_to_engine = BTreeMap::new();
836 let env = BTreeMap::new();
837 let config_vars = BTreeMap::new();
838 let world = World::default();
839 let errs = lower(
840 &scenario,
841 &ctx(
842 &feature,
843 &packs,
844 &kind_to_engine,
845 &env,
846 &config_vars,
847 &world,
848 ),
849 )
850 .unwrap_err();
851 assert_eq!(errs[0].code, "proef::lower::then_before_when");
852 }
853
854 #[test]
857 fn structured_payloads_resolve_placeholders_recursively() {
858 const ALT_KINDS: &[StepKindSpec] = &[StepKindSpec {
859 prefix: "alt",
860 schema: "true",
861 validate: None,
862 }];
863 let packs = pack::load(
864 &[PackSource {
865 name: "alt.yaml".into(),
866 text: Arc::from(
867 "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",
868 ),
869 }],
870 ALT_KINDS,
871 )
872 .unwrap();
873 let feature = crate::feature::parse(
874 "t.feature",
875 "Feature: F\n Scenario: S\n When the alternate step runs\n",
876 )
877 .unwrap();
878 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
879 let kind_to_engine: BTreeMap<String, String> =
880 [("alt".to_owned(), "alt".to_owned())].into();
881 let env = BTreeMap::new();
882 let config_vars =
883 BTreeMap::from([("url:base".to_owned(), "http://fixture.local".to_owned())]);
884 let world = World::default();
885 let lowered = lower(
886 &scenario,
887 &ctx(
888 &feature,
889 &packs,
890 &kind_to_engine,
891 &env,
892 &config_vars,
893 &world,
894 ),
895 )
896 .unwrap();
897 let StepPayload::Structured(value) = &lowered.batches[0].steps[0].payload else {
898 panic!("structured payload expected");
899 };
900 assert_eq!(value["target"], "http://fixture.local/item");
901 assert_eq!(value["checks"][0], "http://fixture.local");
902 assert_eq!(value["checks"][1], 7);
903 }
904
905 #[test]
909 fn expect_merge_scopes_to_the_last_entry() {
910 let packs = pack::load(
911 &[PackSource {
912 name: "multi.yaml".into(),
913 text: Arc::from(
914 "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",
915 ),
916 }],
917 KINDS,
918 )
919 .unwrap();
920 let feature = crate::feature::parse(
921 "t.feature",
922 "Feature: F\n Scenario: S\n When both calls run\n Then the response status is 201\n",
923 )
924 .unwrap();
925 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
926 let kind_to_engine: BTreeMap<String, String> =
927 [("hurl".to_owned(), "hurl".to_owned())].into();
928 let env = BTreeMap::new();
929 let config_vars = BTreeMap::new();
930 let world = World::default();
931 let lowered = lower(
932 &scenario,
933 &ctx(
934 &feature,
935 &packs,
936 &kind_to_engine,
937 &env,
938 &config_vars,
939 &world,
940 ),
941 )
942 .unwrap();
943 let StepPayload::HurlEntries(text) = &lowered.batches[0].steps[0].payload else {
944 panic!("expected hurl entries");
945 };
946 let tail = text.split("GET http://x/b").nth(1).unwrap();
950 assert!(tail.contains("HTTP *"), "{text}");
951 assert!(tail.contains("[Asserts]"), "{text}");
952 assert!(tail.contains("status == 201"), "{text}");
953 }
954
955 #[test]
959 fn baked_options_extend_a_late_author_options_section() {
960 let retry = Some(crate::step::Retry {
961 count: 2,
962 interval_ms: 100,
963 });
964 let body =
965 "GET http://x/a\n[QueryStringParams]\nq: 1\n[Options]\nverbose: true\nHTTP 200\n";
966 let baked = bake_entry_options(body, retry, None);
967 assert_eq!(baked.matches("[Options]").count(), 1, "{baked}");
968 assert!(
969 baked.contains("[Options]\nretry: 2\nretry-interval: 100ms\nverbose: true"),
970 "{baked}"
971 );
972 }
973
974 #[test]
977 fn baked_options_never_enter_bodies() {
978 let retry = Some(crate::step::Retry {
979 count: 2,
980 interval_ms: 100,
981 });
982 for body in [
983 "POST http://x/a\n```\nNOTE FOR REVIEW\nsecond line\n```\nHTTP 200\n",
984 "POST http://x/a\n<root xmlns:x=\"urn:example\">\n <child>hi</child>\n</root>\nHTTP 200\n",
985 "POST http://x/a\n{\"note\": \"FOR REVIEW\"}\nHTTP 200\n",
986 ] {
987 let baked = bake_entry_options(body, retry, None);
988 assert_eq!(
989 baked.matches("[Options]").count(),
990 1,
991 "exactly one options block in:\n{baked}"
992 );
993 let options_at = baked.find("[Options]").unwrap_or(usize::MAX);
994 let body_at = baked
995 .find("```")
996 .or_else(|| baked.find('<'))
997 .or_else(|| baked.find('{'))
998 .unwrap_or(0);
999 assert!(options_at < body_at, "options precede the body:\n{baked}");
1000 }
1001 }
1002
1003 #[test]
1004 fn engine_change_splits_batches() {
1005 let steps: Vec<LoweredStep> = ["hurl", "hurl", "alt", "hurl"]
1006 .iter()
1007 .map(|kind| LoweredStep {
1008 step: StepRef {
1009 file: Arc::from("f"),
1010 line: 1,
1011 text: Arc::from("t"),
1012 },
1013 kind: StepKindId::from(*kind),
1014 payload: StepPayload::HurlEntries(String::new()),
1015 optional: false,
1016 when: None,
1017 label: None,
1018 save_as: BTreeMap::new(),
1019 })
1020 .collect();
1021 let mapping: BTreeMap<String, String> = [
1022 ("hurl".to_owned(), "hurl".to_owned()),
1023 ("alt".to_owned(), "alt".to_owned()),
1024 ]
1025 .into();
1026 let batches = segment(steps, &mapping);
1027 let sizes: Vec<usize> = batches.iter().map(|b| b.steps.len()).collect();
1028 assert_eq!(sizes, vec![2, 1, 1]);
1029 assert_eq!(batches[1].engine.as_str(), "alt");
1030 let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
1032 assert_eq!(indexes, vec![0, 1, 2]);
1033 }
1034}