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 run_id: &'a str,
36 pub world: &'a World,
38 pub config: &'a BTreeMap<String, String>,
40 pub mode: ResolveMode,
42}
43
44#[derive(Debug)]
46pub struct LoweredScenario {
47 pub name: String,
49 pub tags: Vec<String>,
51 pub line: usize,
53 pub batches: Vec<StepBatch>,
55 pub secrets: BTreeSet<String>,
57 pub globals: BTreeSet<String>,
59 pub warnings: Vec<Diag>,
61}
62
63const MAX_EXPANSION_DEPTH: usize = 32;
65
66#[derive(Debug, Default)]
68struct Refs {
69 secrets: BTreeSet<String>,
70 globals: BTreeSet<String>,
71}
72
73pub fn lower(scenario: &BoundScenario, ctx: &LowerCtx<'_>) -> Result<LoweredScenario, Vec<Diag>> {
75 let mut diags: Vec<Diag> = Vec::new();
76 let mut warnings: Vec<Diag> = Vec::new();
77 let mut refs = Refs::default();
78
79 let Some(directives) = resolve_directives(ctx, &mut refs, &mut warnings, &mut diags) else {
81 return Err(diags);
82 };
83
84 let mut lowered: Vec<LoweredStep> = Vec::new();
85 for step in &scenario.steps {
86 let step_ref = StepRef {
87 file: Arc::from(ctx.feature.path.as_str()),
88 line: step.defn.line,
89 text: Arc::from(step.defn.text.as_str()),
90 };
91 let at = |diag: Diag| {
92 diag.with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source))
93 .with_span(step.defn.span)
94 };
95 let Some(macro_) = ctx.packs.macros.get(&step.macro_name) else {
96 continue; };
98 expand_macro(
99 macro_,
100 &step.args,
101 &step_ref,
102 &directives,
103 ctx,
104 0,
105 &mut lowered,
106 &mut refs,
107 &mut warnings,
108 &mut diags,
109 &at,
110 );
111 }
112
113 if diags.iter().any(|d| d.severity == Severity::Error) {
114 return Err(diags);
115 }
116
117 Ok(LoweredScenario {
118 name: scenario.name.clone(),
119 tags: scenario.tags.clone(),
120 line: scenario.line,
121 batches: segment(lowered, ctx.kind_to_engine),
122 secrets: refs.secrets,
123 globals: refs.globals,
124 warnings,
125 })
126}
127
128fn resolve_directives(
131 ctx: &LowerCtx<'_>,
132 refs: &mut Refs,
133 warnings: &mut Vec<Diag>,
134 diags: &mut Vec<Diag>,
135) -> Option<BTreeMap<String, String>> {
136 let empty = BTreeMap::new();
137 let mut resolved = BTreeMap::new();
138 for (key, value) in &ctx.feature.directives {
139 let resolve_ctx = ResolveCtx {
140 args: &empty,
141 defaults: &empty,
142 directives: &resolved, config: ctx.config,
144 env: ctx.env,
145 run_id: ctx.run_id,
146 world: ctx.world,
147 mode: ctx.mode,
148 };
149 match resolve::resolve(value, &resolve_ctx) {
150 Ok(resolution) => {
151 refs.secrets.extend(resolution.secrets);
152 refs.globals.extend(resolution.globals);
153 push_warnings(warnings, &resolution.warnings, ctx, key);
154 resolved.insert(key.clone(), resolution.text);
155 }
156 Err(err) => {
157 diags.push(
158 Diag::error(
159 err.code(),
160 format!("directive `# {key}:` does not resolve: {err}"),
161 )
162 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
163 );
164 return None;
165 }
166 }
167 }
168 Some(resolved)
169}
170
171#[allow(clippy::too_many_arguments)]
173fn expand_macro(
174 macro_: &Macro,
175 args: &BTreeMap<String, String>,
176 step_ref: &StepRef,
177 directives: &BTreeMap<String, String>,
178 ctx: &LowerCtx<'_>,
179 depth: usize,
180 out: &mut Vec<LoweredStep>,
181 refs: &mut Refs,
182 warnings: &mut Vec<Diag>,
183 diags: &mut Vec<Diag>,
184 at: &impl Fn(Diag) -> Diag,
185) {
186 if depth > MAX_EXPANSION_DEPTH {
187 diags.push(at(Diag::error(
188 "proef::lower::expansion_too_deep",
189 format!(
190 "macro expansion exceeded depth {MAX_EXPANSION_DEPTH} at `{}`",
191 macro_.name
192 ),
193 )));
194 return;
195 }
196
197 let resolve_in = |text: &str,
198 refs: &mut Refs,
199 warnings: &mut Vec<Diag>,
200 diags: &mut Vec<Diag>|
201 -> Option<String> {
202 let resolve_ctx = ResolveCtx {
203 args,
204 defaults: ¯o_.defaults,
205 directives,
206 config: ctx.config,
207 env: ctx.env,
208 run_id: ctx.run_id,
209 world: ctx.world,
210 mode: ctx.mode,
211 };
212 match resolve::resolve(text, &resolve_ctx) {
213 Ok(resolution) => {
214 refs.secrets.extend(resolution.secrets);
215 refs.globals.extend(resolution.globals);
216 push_warnings(warnings, &resolution.warnings, ctx, ¯o_.name);
217 Some(resolution.text)
218 }
219 Err(err) => {
220 diags.push(at(Diag::error(
221 err.code(),
222 format!("in macro `{}`: {err}", macro_.name),
223 )));
224 None
225 }
226 }
227 };
228
229 match ¯o_.body {
230 MacroBody::Expect(items) => {
231 for item in items {
232 let status = match &item.status {
233 Some(status) => match resolve_in(status, refs, warnings, diags) {
234 Some(status) => Some(status),
235 None => continue,
236 },
237 None => None,
238 };
239 let fragment = match &item.fragment {
240 Some(fragment) => match resolve_in(fragment, refs, warnings, diags) {
241 Some(fragment) => Some(fragment),
242 None => continue,
243 },
244 None => None,
245 };
246 merge_expect(status.as_deref(), fragment.as_deref(), out, diags, at);
247 }
248 }
249 MacroBody::Steps(steps) => {
250 for macro_step in steps {
251 expand_step(
252 macro_step,
253 step_ref,
254 directives,
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 directives: &BTreeMap<String, String>,
275 ctx: &LowerCtx<'_>,
276 depth: usize,
277 out: &mut Vec<LoweredStep>,
278 refs: &mut Refs,
279 warnings: &mut Vec<Diag>,
280 diags: &mut Vec<Diag>,
281 at: &impl Fn(Diag) -> Diag,
282 resolve_in: &impl Fn(&str, &mut Refs, &mut Vec<Diag>, &mut Vec<Diag>) -> Option<String>,
283) {
284 match ¯o_step.kind {
285 MacroStepKind::Use { target, with } => {
286 let Some(target_macro) = ctx.packs.find_use_target(target) else {
287 return; };
289 let mut child_args = BTreeMap::new();
292 for (key, value) in with {
293 if let Some(resolved) = resolve_in(value, refs, warnings, diags) {
294 child_args.insert(key.clone(), resolved);
295 }
296 }
297 expand_macro(
298 target_macro,
299 &child_args,
300 step_ref,
301 directives,
302 ctx,
303 depth + 1,
304 out,
305 refs,
306 warnings,
307 diags,
308 at,
309 );
310 }
311 MacroStepKind::Payload { kind, payload } => {
312 let payload = match payload {
313 PayloadForm::Raw(text) => {
314 let Some(resolved) = resolve_in(text, refs, warnings, diags) else {
315 return;
316 };
317 let resolved = if macro_step.retry.is_some() || macro_step.delay_ms.is_some() {
321 bake_entry_options(&resolved, macro_step.retry, macro_step.delay_ms)
322 } else {
323 resolved
324 };
325 StepPayload::HurlEntries(resolved)
326 }
327 PayloadForm::Structured(value) => {
328 let mut resolve = |text: &str| {
332 if !text.contains('$') {
335 return Some(text.to_owned());
336 }
337 resolve_in(text, refs, warnings, diags)
338 };
339 match resolve_structured(value, &mut resolve) {
340 Some(resolved) => StepPayload::Structured(resolved),
341 None => return,
342 }
343 }
344 };
345 let when = match ¯o_step.when {
346 Some(guard) => match resolve_in(guard, refs, warnings, diags) {
347 Some(resolved) => Some(Guard(resolved)),
348 None => return,
349 },
350 None => None,
351 };
352 let label = match ¯o_step.name {
355 Some(name) => match resolve_in(name, refs, warnings, diags) {
356 Some(resolved) => Some(resolved),
357 None => return,
358 },
359 None => None,
360 };
361 out.push(LoweredStep {
362 step: step_ref.clone(),
363 kind: StepKindId::from(kind.as_str()),
364 payload,
365 optional: macro_step.optional,
366 retry: macro_step.retry,
367 when,
368 label,
369 save_as: macro_step.save_as.clone(),
370 });
371 }
372 }
373}
374
375fn resolve_structured(
378 value: &serde_json::Value,
379 resolve: &mut dyn FnMut(&str) -> Option<String>,
380) -> Option<serde_json::Value> {
381 use serde_json::Value as J;
382 Some(match value {
383 J::String(text) => J::String(resolve(text)?),
384 J::Array(items) => J::Array(
385 items
386 .iter()
387 .map(|item| resolve_structured(item, resolve))
388 .collect::<Option<_>>()?,
389 ),
390 J::Object(map) => {
391 let mut out = serde_json::Map::new();
392 for (key, item) in map {
393 out.insert(key.clone(), resolve_structured(item, resolve)?);
394 }
395 J::Object(out)
396 }
397 other => other.clone(),
398 })
399}
400
401fn bake_entry_options(
408 text: &str,
409 retry: Option<crate::step::Retry>,
410 delay_ms: Option<u64>,
411) -> String {
412 let mut option_lines: Vec<String> = Vec::new();
413 if let Some(retry) = retry {
414 option_lines.push(format!("retry: {}", retry.count));
415 option_lines.push(format!("retry-interval: {}ms", retry.interval_ms));
416 }
417 if let Some(delay_ms) = delay_ms {
418 option_lines.push(format!("delay: {delay_ms}ms"));
419 }
420 let retry_lines = option_lines.join("\n");
421 let mut out: Vec<String> = Vec::new();
422 let mut in_entry_head = false; let mut injected_current = false;
424 let mut in_fence = false; for line in text.lines() {
426 let trimmed = line.trim();
427 if trimmed.starts_with("```") {
428 if !in_fence && in_entry_head && !injected_current {
431 out.push("[Options]".to_owned());
432 out.push(retry_lines.clone());
433 injected_current = true;
434 }
435 in_fence = !in_fence;
436 in_entry_head = false;
437 out.push(line.to_owned());
438 continue;
439 }
440 if in_fence {
441 out.push(line.to_owned());
442 continue;
443 }
444 let is_method_line = trimmed.split_whitespace().next().is_some_and(|word| {
445 word.len() >= 3
446 && word.chars().all(|c| c.is_ascii_uppercase() || c == '-')
447 && word != "HTTP"
448 }) && trimmed.split_whitespace().count() >= 2;
449 if is_method_line {
450 in_entry_head = true;
451 injected_current = false;
452 out.push(line.to_owned());
453 continue;
454 }
455 if trimmed == "[Options]" {
456 out.push(line.to_owned());
458 out.push(retry_lines.clone());
459 injected_current = true;
460 in_entry_head = false;
461 continue;
462 }
463 let is_header = in_entry_head && is_header_line(trimmed);
464 if in_entry_head && !is_header && !injected_current {
465 out.push("[Options]".to_owned());
466 out.push(retry_lines.clone());
467 injected_current = true;
468 in_entry_head = false;
469 }
470 out.push(line.to_owned());
471 }
472 if in_entry_head && !injected_current {
473 out.push("[Options]".to_owned());
474 out.push(retry_lines.clone());
475 }
476 let mut result = out.join("\n");
477 if text.ends_with('\n') {
478 result.push('\n');
479 }
480 result
481}
482
483fn merge_expect(
487 status: Option<&str>,
488 fragment: Option<&str>,
489 out: &mut [LoweredStep],
490 diags: &mut Vec<Diag>,
491 at: &impl Fn(Diag) -> Diag,
492) {
493 let Some(previous) = out
494 .iter_mut()
495 .rev()
496 .find(|s| matches!(s.payload, StepPayload::HurlEntries(_)))
497 else {
498 diags.push(
499 at(Diag::error(
500 "proef::lower::then_before_when",
501 "this assert-only step has no previous request entry to attach to",
502 ))
503 .with_help("a Then step asserts on the request made by an earlier When step"),
504 );
505 return;
506 };
507
508 if let Some(status) = status
509 && (!status.chars().all(|c| c.is_ascii_digit()) || status.is_empty())
510 {
511 diags.push(at(Diag::error(
512 "proef::lower::bad_status",
513 format!("expected an HTTP status number, got `{status}`"),
514 )));
515 return;
516 }
517
518 let StepPayload::HurlEntries(text) = &mut previous.payload else {
519 return;
520 };
521 if !text.lines().any(|l| l.trim_start().starts_with("HTTP")) {
524 push_line(text, "HTTP *");
525 }
526 if !text.lines().any(|l| l.trim() == "[Asserts]") {
527 push_line(text, "[Asserts]");
528 }
529 if let Some(status) = status {
530 push_line(text, &format!("status == {status}"));
531 }
532 if let Some(fragment) = fragment {
533 for line in fragment.lines().filter(|l| !l.trim().is_empty()) {
534 push_line(text, line.trim_end());
535 }
536 }
537}
538
539fn is_header_line(trimmed: &str) -> bool {
544 let Some((name, _)) = trimmed.split_once(':') else {
545 return false;
546 };
547 !name.is_empty()
548 && name != "HTTP"
549 && name
550 .chars()
551 .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c))
552}
553
554fn push_line(text: &mut String, line: &str) {
555 if !text.is_empty() && !text.ends_with('\n') {
556 text.push('\n');
557 }
558 text.push_str(line);
559 text.push('\n');
560}
561
562fn segment(steps: Vec<LoweredStep>, kind_to_engine: &BTreeMap<String, String>) -> Vec<StepBatch> {
566 let mut batches: Vec<StepBatch> = Vec::new();
567 for step in steps {
568 let engine = kind_to_engine
569 .get(step.kind.as_str())
570 .map_or_else(|| step.kind.as_str().to_owned(), Clone::clone);
571 let start_new = match batches.last() {
572 None => true,
573 Some(last) => {
574 last.engine.as_str() != engine
575 || step.optional
576 || last.steps.last().is_some_and(|s| s.optional)
577 }
578 };
579 if start_new {
580 batches.push(StepBatch {
581 index: batches.len(),
582 engine: crate::engine::EngineId::from(engine.as_str()),
583 steps: vec![step],
584 });
585 } else if let Some(last) = batches.last_mut() {
586 last.steps.push(step);
587 }
588 }
589 batches
590}
591
592fn push_warnings(warnings: &mut Vec<Diag>, texts: &[String], ctx: &LowerCtx<'_>, where_: &str) {
593 for text in texts {
594 warnings.push(
595 Diag::warning("proef::lower::dry_run_unknown", format!("{where_}: {text}"))
596 .with_source(ctx.feature.path.clone(), Arc::clone(&ctx.feature.source)),
597 );
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 #![allow(clippy::unwrap_used)]
604
605 use super::*;
606 use crate::engine::StepKindSpec;
607 use crate::pack::{self, PackSource};
608 use crate::step::StepPayload;
609
610 const KINDS: &[StepKindSpec] = &[StepKindSpec {
611 prefix: "hurl",
612 schema: "true",
613 validate: None,
614 }];
615
616 const PACK: &str = r#"templates:
617 auth:
618 params: [token]
619 steps:
620 - name: authenticate
621 hurl: |
622 POST ${baseURL}/auth
623 Authorization: Bearer ${token}
624 HTTP 200
625 search:
626 params: [term]
627 match: "I search for {term}"
628 steps:
629 - use: auth
630 with: { token: "${secret:apiToken}" }
631 - name: run the search
632 hurl: |
633 GET ${baseURL}/search?q=${term}
634 HTTP 200
635 [Captures]
636 clientId: jsonpath "$[0].id"
637 checkHealth:
638 match: the service is healthy
639 steps:
640 - optional: true
641 hurl: |
642 GET ${baseURL}/health
643 expectStatus:
644 params: [status]
645 match: "the response status is {status}"
646 expect:
647 - status: "${status}"
648"#;
649
650 fn fixture() -> (
651 crate::feature::FeatureFile,
652 crate::bind::BoundScenario,
653 PackSet,
654 ) {
655 let packs = pack::load(
656 &[PackSource {
657 name: "test.yaml".into(),
658 text: Arc::from(PACK),
659 }],
660 KINDS,
661 )
662 .unwrap();
663 let feature = crate::feature::parse(
664 "t.feature",
665 "# baseURL: http://fixture.local\nFeature: F\n Scenario: S\n Given the service is healthy\n When I search for \"Jansen\"\n Then the response status is 200\n",
666 )
667 .unwrap();
668 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
669 (feature, scenario, packs)
670 }
671
672 fn ctx<'a>(
673 feature: &'a crate::feature::FeatureFile,
674 packs: &'a PackSet,
675 kind_to_engine: &'a BTreeMap<String, String>,
676 env: &'a BTreeMap<String, String>,
677 world: &'a World,
678 config: &'a BTreeMap<String, String>,
679 ) -> LowerCtx<'a> {
680 LowerCtx {
681 feature,
682 packs,
683 kind_to_engine,
684 env,
685 run_id: "run-0001",
686 world,
687 config,
688 mode: ResolveMode::DryRun,
689 }
690 }
691
692 #[test]
693 fn expansion_resolution_merge_and_segmentation_work_together() {
694 let (feature, scenario, packs) = fixture();
695 let kind_to_engine: BTreeMap<String, String> =
696 [("hurl".to_owned(), "hurl".to_owned())].into();
697 let (env, config) = (BTreeMap::new(), BTreeMap::new());
698 let world = World::default();
699 let lowered = lower(
700 &scenario,
701 &ctx(&feature, &packs, &kind_to_engine, &env, &world, &config),
702 )
703 .unwrap();
704
705 assert_eq!(lowered.batches.len(), 2);
707 assert_eq!(lowered.batches[0].steps.len(), 1);
708 assert!(lowered.batches[0].steps[0].optional);
709 assert_eq!(lowered.batches[1].steps.len(), 2);
710
711 let StepPayload::HurlEntries(auth) = &lowered.batches[1].steps[0].payload else {
713 panic!("expected hurl entries");
714 };
715 assert!(auth.contains("POST http://fixture.local/auth"), "{auth}");
716 assert!(
717 auth.contains("Bearer {{apiToken}}"),
718 "secret placeholder: {auth}"
719 );
720 assert!(lowered.secrets.contains("apiToken"));
721
722 let StepPayload::HurlEntries(search) = &lowered.batches[1].steps[1].payload else {
724 panic!("expected hurl entries");
725 };
726 assert!(
727 search.contains("GET http://fixture.local/search?q=Jansen"),
728 "{search}"
729 );
730 assert!(search.contains("[Asserts]"), "{search}");
731 assert!(search.trim_end().ends_with("status == 200"), "{search}");
732
733 assert_eq!(lowered.batches[1].steps[1].step.line, 5);
735 assert_eq!(
736 lowered.batches[1].steps[0].label.as_deref(),
737 Some("authenticate")
738 );
739 }
740
741 #[test]
742 fn then_before_when_is_an_error() {
743 let (_, _, packs) = fixture();
744 let feature = crate::feature::parse(
745 "t.feature",
746 "Feature: F\n Scenario: S\n Then the response status is 200\n",
747 )
748 .unwrap();
749 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
750 let kind_to_engine = BTreeMap::new();
751 let (env, config) = (BTreeMap::new(), BTreeMap::new());
752 let world = World::default();
753 let errs = lower(
754 &scenario,
755 &ctx(&feature, &packs, &kind_to_engine, &env, &world, &config),
756 )
757 .unwrap_err();
758 assert_eq!(errs[0].code, "proef::lower::then_before_when");
759 }
760
761 #[test]
764 fn structured_payloads_resolve_placeholders_recursively() {
765 const WEB_KINDS: &[StepKindSpec] = &[StepKindSpec {
766 prefix: "web",
767 schema: "true",
768 validate: None,
769 }];
770 let packs = pack::load(
771 &[PackSource {
772 name: "web.yaml".into(),
773 text: Arc::from(
774 "templates:\n open:\n match: the page is opened\n steps:\n - name: open\n web:\n goto: \"${baseURL}/page\"\n checks: [\"${baseURL}\", 7]\n",
775 ),
776 }],
777 WEB_KINDS,
778 )
779 .unwrap();
780 let feature = crate::feature::parse(
781 "t.feature",
782 "# baseURL: http://fixture.local\nFeature: F\n Scenario: S\n When the page is opened\n",
783 )
784 .unwrap();
785 let scenario = crate::bind::bind(&feature, &packs).unwrap().remove(0);
786 let kind_to_engine: BTreeMap<String, String> =
787 [("web".to_owned(), "web".to_owned())].into();
788 let (env, config) = (BTreeMap::new(), BTreeMap::new());
789 let world = World::default();
790 let lowered = lower(
791 &scenario,
792 &ctx(&feature, &packs, &kind_to_engine, &env, &world, &config),
793 )
794 .unwrap();
795 let StepPayload::Structured(value) = &lowered.batches[0].steps[0].payload else {
796 panic!("structured payload expected");
797 };
798 assert_eq!(value["goto"], "http://fixture.local/page");
799 assert_eq!(value["checks"][0], "http://fixture.local");
800 assert_eq!(value["checks"][1], 7);
801 }
802
803 #[test]
806 fn baked_options_never_enter_bodies() {
807 let retry = Some(crate::step::Retry {
808 count: 2,
809 interval_ms: 100,
810 });
811 for body in [
812 "POST http://x/a\n```\nNOTE FOR REVIEW\nsecond line\n```\nHTTP 200\n",
813 "POST http://x/a\n<root xmlns:x=\"urn:example\">\n <child>hi</child>\n</root>\nHTTP 200\n",
814 "POST http://x/a\n{\"note\": \"FOR REVIEW\"}\nHTTP 200\n",
815 ] {
816 let baked = bake_entry_options(body, retry, None);
817 assert_eq!(
818 baked.matches("[Options]").count(),
819 1,
820 "exactly one options block in:\n{baked}"
821 );
822 let options_at = baked.find("[Options]").unwrap_or(usize::MAX);
823 let body_at = baked
824 .find("```")
825 .or_else(|| baked.find('<'))
826 .or_else(|| baked.find('{'))
827 .unwrap_or(0);
828 assert!(options_at < body_at, "options precede the body:\n{baked}");
829 }
830 }
831
832 #[test]
833 fn engine_change_splits_batches() {
834 let steps: Vec<LoweredStep> = ["hurl", "hurl", "web", "hurl"]
835 .iter()
836 .map(|kind| LoweredStep {
837 step: StepRef {
838 file: Arc::from("f"),
839 line: 1,
840 text: Arc::from("t"),
841 },
842 kind: StepKindId::from(*kind),
843 payload: StepPayload::HurlEntries(String::new()),
844 optional: false,
845 retry: None,
846 when: None,
847 label: None,
848 save_as: BTreeMap::new(),
849 })
850 .collect();
851 let mapping: BTreeMap<String, String> = [
852 ("hurl".to_owned(), "hurl".to_owned()),
853 ("web".to_owned(), "web".to_owned()),
854 ]
855 .into();
856 let batches = segment(steps, &mapping);
857 let sizes: Vec<usize> = batches.iter().map(|b| b.steps.len()).collect();
858 assert_eq!(sizes, vec![2, 1, 1]);
859 assert_eq!(batches[1].engine.as_str(), "web");
860 let indexes: Vec<usize> = batches.iter().map(|b| b.index).collect();
862 assert_eq!(indexes, vec![0, 1, 2]);
863 }
864}