1use crate::error::{BenchError, Result};
4use indicatif::{ProgressBar, ProgressStyle};
5use std::path::Path;
6use std::process::Stdio;
7use std::sync::Arc;
8use tokio::io::{AsyncBufReadExt, BufReader};
9use tokio::process::Command as TokioCommand;
10
11fn extract_mockforge_marker_json(line: &str, marker: &str) -> Option<String> {
34 let start = line.find(marker)?;
35 let json_start = start + marker.len();
36 let rest = &line[json_start..];
37
38 let is_logfmt = start >= 5 && line.as_bytes().get(start - 5..start) == Some(b"msg=\"");
42 if is_logfmt {
43 let bytes = rest.as_bytes();
48 let mut i = 0;
49 let mut out = String::with_capacity(rest.len());
50 while i < bytes.len() {
51 let b = bytes[i];
52 if b == b'"' {
53 return Some(out);
55 }
56 if b == b'\\' && i + 1 < bytes.len() {
57 let next = bytes[i + 1];
58 match next {
59 b'"' => out.push('"'),
60 b'\\' => out.push('\\'),
61 other => {
65 out.push('\\');
66 out.push(other as char);
67 }
68 }
69 i += 2;
70 continue;
71 }
72 let ch_start = i;
75 i += 1;
77 while i < bytes.len() && (bytes[i] & 0b1100_0000) == 0b1000_0000 {
78 i += 1;
79 }
80 out.push_str(&rest[ch_start..i]);
81 }
82 if out.is_empty() {
85 None
86 } else {
87 Some(out)
88 }
89 } else {
90 let trimmed = rest.trim();
93 if trimmed.is_empty() {
94 None
95 } else {
96 Some(trimmed.to_string())
97 }
98 }
99}
100
101fn extract_exchange_json(line: &str) -> Option<String> {
103 extract_mockforge_marker_json(line, "MOCKFORGE_EXCHANGE:")
104}
105
106fn extract_failure_json(line: &str) -> Option<String> {
108 extract_mockforge_marker_json(line, "MOCKFORGE_FAILURE:")
109}
110
111fn extract_network_event_json(line: &str) -> Option<String> {
115 extract_mockforge_marker_json(line, "MOCKFORGE_NETWORK_EVENT:")
116}
117
118pub(crate) fn merge_godebug_http2client_off(existing: Option<&str>) -> String {
123 const FLAG: &str = "http2client=0";
124 let existing = existing.unwrap_or("").trim();
125 if existing.is_empty() {
126 return FLAG.to_string();
127 }
128 if existing.split(',').any(|part| part.trim().starts_with("http2client=")) {
129 return existing.to_string();
130 }
131 format!("{existing},{FLAG}")
132}
133
134pub struct K6Executor {
136 k6_path: String,
137 local_ips: String,
143 discard_response_bodies: bool,
150 dns_policy: String,
158 force_http1: bool,
163 capture_logs: bool,
172}
173
174impl K6Executor {
175 pub fn new() -> Result<Self> {
177 let k6_path = which::which("k6")
178 .map_err(|_| BenchError::K6NotFound)?
179 .to_string_lossy()
180 .to_string();
181
182 Ok(Self {
183 k6_path,
184 local_ips: String::new(),
185 discard_response_bodies: false,
186 dns_policy: String::new(),
187 force_http1: false,
188 capture_logs: true,
189 })
190 }
191
192 pub fn with_local_ips(mut self, local_ips: impl Into<String>) -> Self {
196 self.local_ips = local_ips.into();
197 self
198 }
199
200 pub fn with_discard_response_bodies(mut self, discard: bool) -> Self {
204 self.discard_response_bodies = discard;
205 self
206 }
207
208 pub fn with_dns_policy(mut self, policy: impl Into<String>) -> Self {
212 self.dns_policy = policy.into();
213 self
214 }
215
216 pub fn with_force_http1(mut self, force: bool) -> Self {
220 self.force_http1 = force;
221 self
222 }
223
224 pub fn with_capture_logs(mut self, capture: bool) -> Self {
229 self.capture_logs = capture;
230 self
231 }
232
233 #[cfg(test)]
236 pub(crate) fn godebug_env_value(&self) -> Option<String> {
237 if !self.force_http1 {
238 return None;
239 }
240 Some(merge_godebug_http2client_off(std::env::var("GODEBUG").ok().as_deref()))
241 }
242
243 pub fn is_k6_installed() -> bool {
245 which::which("k6").is_ok()
246 }
247
248 pub async fn warn_if_pre_v1() {
253 const DOCS: &str =
254 "See https://mockforge.dev/docs/reference/bench-capacity-sizing#tooling-requirements";
255 let output = match TokioCommand::new("k6").arg("version").output().await {
256 Ok(o) if o.status.success() => o,
257 _ => return,
258 };
259 let stdout = String::from_utf8_lossy(&output.stdout);
260 let Some(major) = stdout
261 .split_whitespace()
262 .find_map(|t| t.strip_prefix('v'))
263 .and_then(|rest| rest.split('.').next())
264 .and_then(|major| major.parse::<u64>().ok())
265 else {
266 return;
267 };
268 if major < 1 {
269 crate::reporter::TerminalReporter::print_warning(&format!(
270 "Detected k6 {}.x; mockforge bench requires k6 >= 1.0.0 \
271 (older versions break executors and summary parsing). Upgrade k6. {DOCS}",
272 major
273 ));
274 }
275 }
276
277 pub async fn get_version(&self) -> Result<String> {
279 let output = TokioCommand::new(&self.k6_path)
280 .arg("version")
281 .output()
282 .await
283 .map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
284
285 if !output.status.success() {
286 return Err(BenchError::K6ExecutionFailed("Failed to get k6 version".to_string()));
287 }
288
289 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
290 }
291
292 pub async fn execute(
302 &self,
303 script_path: &Path,
304 output_dir: Option<&Path>,
305 verbose: bool,
306 ) -> Result<K6Results> {
307 self.execute_with_port(script_path, output_dir, verbose, None).await
308 }
309
310 pub async fn execute_with_port(
312 &self,
313 script_path: &Path,
314 output_dir: Option<&Path>,
315 verbose: bool,
316 api_port: Option<u16>,
317 ) -> Result<K6Results> {
318 println!("Starting load test...\n");
319
320 let mut cmd = TokioCommand::new(&self.k6_path);
321 cmd.arg("run");
322
323 if let Some(port) = api_port {
326 cmd.arg("--address").arg(format!("localhost:{}", port));
327 }
328
329 if !self.local_ips.is_empty() {
334 cmd.arg("--local-ips").arg(&self.local_ips);
335 }
336
337 if self.discard_response_bodies {
340 cmd.env("K6_DISCARD_RESPONSE_BODIES", "true");
341 }
342
343 if self.force_http1 {
347 let existing = std::env::var("GODEBUG").ok();
348 cmd.env("GODEBUG", merge_godebug_http2client_off(existing.as_deref()));
349 }
350
351 if !self.dns_policy.is_empty() {
356 cmd.arg("--dns").arg(format!("policy={}", self.dns_policy));
357 }
358
359 if verbose {
366 cmd.arg("--verbose");
367 }
368
369 let abs_script =
371 std::fs::canonicalize(script_path).unwrap_or_else(|_| script_path.to_path_buf());
372 cmd.arg(&abs_script);
373
374 if let Some(dir) = output_dir {
377 cmd.current_dir(dir);
378 }
379
380 cmd.stdout(Stdio::piped());
381 cmd.stderr(Stdio::piped());
382
383 let mut child = cmd.spawn().map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
384
385 let stdout = child
386 .stdout
387 .take()
388 .ok_or_else(|| BenchError::K6ExecutionFailed("Failed to capture stdout".to_string()))?;
389
390 let stderr = child
391 .stderr
392 .take()
393 .ok_or_else(|| BenchError::K6ExecutionFailed("Failed to capture stderr".to_string()))?;
394
395 let stdout_reader = BufReader::new(stdout);
397 let stderr_reader = BufReader::new(stderr);
398
399 let mut stdout_lines = stdout_reader.lines();
400 let mut stderr_lines = stderr_reader.lines();
401
402 let spinner = ProgressBar::new_spinner();
404 spinner.set_style(
405 ProgressStyle::default_spinner().template("{spinner:.green} {msg}").unwrap(),
406 );
407 spinner.set_message("Running load test...");
408
409 let failure_details: Arc<tokio::sync::Mutex<Vec<String>>> =
412 Arc::new(tokio::sync::Mutex::new(Vec::new()));
413 let fd_stdout = Arc::clone(&failure_details);
414 let fd_stderr = Arc::clone(&failure_details);
415
416 let exchange_details: Arc<tokio::sync::Mutex<Vec<String>>> =
418 Arc::new(tokio::sync::Mutex::new(Vec::new()));
419 let ex_stdout = Arc::clone(&exchange_details);
420 let ex_stderr = Arc::clone(&exchange_details);
421
422 let network_events: Arc<tokio::sync::Mutex<Vec<String>>> =
427 Arc::new(tokio::sync::Mutex::new(Vec::new()));
428 let ne_stdout = Arc::clone(&network_events);
429 let ne_stderr = Arc::clone(&network_events);
430
431 let log_lines: Arc<tokio::sync::Mutex<Vec<String>>> =
433 Arc::new(tokio::sync::Mutex::new(Vec::new()));
434 let log_stdout = Arc::clone(&log_lines);
435 let log_stderr = Arc::clone(&log_lines);
436
437 let capture_logs = self.capture_logs;
441 let capture_stdout = capture_logs;
442 let capture_stderr = capture_logs;
443
444 let stdout_handle = tokio::spawn(async move {
446 while let Ok(Some(line)) = stdout_lines.next_line().await {
447 if capture_stdout {
448 log_stdout.lock().await.push(format!("[stdout] {}", line));
449 }
450 if let Some(json_str) = extract_failure_json(&line) {
451 if capture_stdout {
452 fd_stdout.lock().await.push(json_str);
453 }
454 } else if let Some(json_str) = extract_exchange_json(&line) {
455 ex_stdout.lock().await.push(json_str);
456 } else if let Some(json_str) = extract_network_event_json(&line) {
457 if capture_stdout {
458 ne_stdout.lock().await.push(json_str);
459 }
460 } else {
461 spinner.set_message(line.clone());
462 if !line.is_empty() && !line.contains("running") && !line.contains("default") {
463 println!("{}", line);
464 }
465 }
466 }
467 spinner.finish_and_clear();
468 });
469
470 let stderr_handle = tokio::spawn(async move {
472 while let Ok(Some(line)) = stderr_lines.next_line().await {
473 if !line.is_empty() {
474 if capture_stderr {
475 log_stderr.lock().await.push(format!("[stderr] {}", line));
476 }
477 if let Some(json_str) = extract_failure_json(&line) {
478 if capture_stderr {
479 fd_stderr.lock().await.push(json_str);
480 }
481 } else if let Some(json_str) = extract_exchange_json(&line) {
482 ex_stderr.lock().await.push(json_str);
483 } else if let Some(json_str) = extract_network_event_json(&line) {
484 if capture_stderr {
485 ne_stderr.lock().await.push(json_str);
486 }
487 } else {
488 eprintln!("{}", line);
489 }
490 }
491 }
492 });
493
494 let status =
496 child.wait().await.map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
497
498 let _ = stdout_handle.await;
500 let _ = stderr_handle.await;
501
502 let exit_code = status.code().unwrap_or(-1);
510 if !status.success() && exit_code != 99 {
511 let oom_hint = {
512 #[cfg(unix)]
513 {
514 use std::os::unix::process::ExitStatusExt;
515 if status.signal() == Some(9) {
516 " — process received SIGKILL (signal 9). This is usually the \
517 Linux OOM killer, not a k6 bug. For huge OpenAPI specs or \
518 multi-hour longevity runs: pass --no-per-op-metrics, lower \
519 --max-concurrency, and/or --discard-response-bodies; ensure \
520 the client has enough RAM (see docs.mockforge.dev/reference/\
521 bench-capacity-sizing.html)."
522 } else {
523 ""
524 }
525 }
526 #[cfg(not(unix))]
527 {
528 ""
529 }
530 };
531 return Err(BenchError::K6ExecutionFailed(format!(
532 "k6 exited with status: {}{}",
533 status, oom_hint
534 )));
535 }
536 if exit_code == 99 {
537 tracing::warn!("k6 thresholds crossed (exit code 99) — results will still be parsed");
538 }
539
540 if let Some(dir) = output_dir {
542 if capture_logs {
543 let details = failure_details.lock().await;
544 if !details.is_empty() {
545 let failure_path = dir.join("conformance-failure-details.json");
546 let parsed: Vec<serde_json::Value> =
547 details.iter().filter_map(|s| serde_json::from_str(s).ok()).collect();
548 if let Ok(json) = serde_json::to_string_pretty(&parsed) {
549 let _ = std::fs::write(&failure_path, json);
550 }
551 }
552 }
553
554 let exchanges = exchange_details.lock().await;
558 if !exchanges.is_empty() {
559 let exchange_path = dir.join("conformance-requests.json");
560 let parsed: Vec<serde_json::Value> =
561 exchanges.iter().filter_map(|s| serde_json::from_str(s).ok()).collect();
562 if let Ok(json) = serde_json::to_string_pretty(&parsed) {
563 let _ = std::fs::write(&exchange_path, json);
564 tracing::info!(
565 "Exported {} request/response pairs to {}",
566 parsed.len(),
567 exchange_path.display()
568 );
569 }
570 }
571
572 if capture_logs {
573 let net_events = network_events.lock().await;
580 let net_path = dir.join("conformance-network-events.json");
581 let parsed: Vec<serde_json::Value> =
582 net_events.iter().filter_map(|s| serde_json::from_str(s).ok()).collect();
583 if let Ok(json) = serde_json::to_string_pretty(&parsed) {
584 let _ = std::fs::write(&net_path, json);
585 if !parsed.is_empty() {
586 tracing::warn!(
587 "Recorded {} wire-level network event(s) to {}",
588 parsed.len(),
589 net_path.display()
590 );
591 }
592 }
593
594 let lines = log_lines.lock().await;
596 if !lines.is_empty() {
597 let log_path = dir.join("k6-output.log");
598 let _ = std::fs::write(&log_path, lines.join("\n"));
599 println!("k6 output log saved to: {}", log_path.display());
600 }
601 }
602 }
603
604 let results = if let Some(dir) = output_dir {
606 Self::parse_results(dir)?
607 } else {
608 K6Results::default()
609 };
610
611 Ok(results)
612 }
613
614 fn parse_results(output_dir: &Path) -> Result<K6Results> {
616 let summary_path = output_dir.join("summary.json");
617
618 if !summary_path.exists() {
619 return Ok(K6Results::default());
620 }
621
622 let content = std::fs::read_to_string(summary_path)
623 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
624
625 let json: serde_json::Value = serde_json::from_str(&content)
626 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
627
628 let duration_values = &json["metrics"]["http_req_duration"]["values"];
629
630 let server_latency = &json["metrics"]["mockforge_server_injected_latency_ms"]["values"];
631 let server_jitter = &json["metrics"]["mockforge_server_injected_jitter_ms"]["values"];
632 let server_fault = &json["metrics"]["mockforge_server_fault_total"]["values"]["count"];
633
634 let tcp_connecting = &json["metrics"]["http_req_connecting"]["values"];
645 let tls_handshake = &json["metrics"]["http_req_tls_handshaking"]["values"];
646 let mf_conns_opened = &json["metrics"]["mockforge_connections_opened"]["values"]["count"];
647
648 Ok(K6Results {
649 total_requests: json["metrics"]["http_reqs"]["values"]["count"].as_u64().unwrap_or(0),
650 failed_requests: json["metrics"]["http_req_failed"]["values"]["passes"]
654 .as_u64()
655 .unwrap_or(0),
656 avg_duration_ms: duration_values["avg"].as_f64().unwrap_or(0.0),
657 p95_duration_ms: duration_values["p(95)"].as_f64().unwrap_or(0.0),
658 p99_duration_ms: duration_values["p(99)"].as_f64().unwrap_or(0.0),
659 rps: json["metrics"]["http_reqs"]["values"]["rate"].as_f64().unwrap_or(0.0),
660 vus_max: json["metrics"]["vus_max"]["values"]["value"].as_u64().unwrap_or(0) as u32,
661 min_duration_ms: duration_values["min"].as_f64().unwrap_or(0.0),
662 max_duration_ms: duration_values["max"].as_f64().unwrap_or(0.0),
663 med_duration_ms: duration_values["med"].as_f64().unwrap_or(0.0),
664 p90_duration_ms: duration_values["p(90)"].as_f64().unwrap_or(0.0),
665 server_injected_latency_samples: server_latency["count"].as_u64().unwrap_or(0),
666 server_injected_latency_avg_ms: server_latency["avg"].as_f64().unwrap_or(0.0),
667 server_injected_latency_max_ms: server_latency["max"].as_f64().unwrap_or(0.0),
668 server_injected_jitter_samples: server_jitter["count"].as_u64().unwrap_or(0),
669 server_injected_jitter_avg_ms: server_jitter["avg"].as_f64().unwrap_or(0.0),
670 server_reported_faults: server_fault.as_u64().unwrap_or(0),
671 tcp_connect_samples: mf_conns_opened.as_u64().unwrap_or(0),
674 tcp_connect_avg_ms: tcp_connecting["avg"].as_f64().unwrap_or(0.0),
675 tcp_connect_max_ms: tcp_connecting["max"].as_f64().unwrap_or(0.0),
676 tls_handshake_samples: if tls_handshake["avg"].as_f64().unwrap_or(0.0) > 0.0 {
678 mf_conns_opened.as_u64().unwrap_or(0)
681 } else {
682 0
683 },
684 tls_handshake_avg_ms: tls_handshake["avg"].as_f64().unwrap_or(0.0),
685 tls_handshake_max_ms: tls_handshake["max"].as_f64().unwrap_or(0.0),
686 iterations_completed: json["metrics"]["iterations"]["values"]["count"]
687 .as_u64()
688 .unwrap_or(0),
689 })
690 }
691}
692
693impl Default for K6Executor {
694 fn default() -> Self {
695 Self::new().expect("k6 not found")
696 }
697}
698
699#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
701pub struct K6Results {
702 pub total_requests: u64,
703 pub failed_requests: u64,
704 pub avg_duration_ms: f64,
705 pub p95_duration_ms: f64,
706 pub p99_duration_ms: f64,
707 pub rps: f64,
708 pub vus_max: u32,
709 pub min_duration_ms: f64,
710 pub max_duration_ms: f64,
711 pub med_duration_ms: f64,
712 pub p90_duration_ms: f64,
713 #[serde(default)]
718 pub server_injected_latency_samples: u64,
719 #[serde(default)]
720 pub server_injected_latency_avg_ms: f64,
721 #[serde(default)]
722 pub server_injected_latency_max_ms: f64,
723 #[serde(default)]
724 pub server_injected_jitter_samples: u64,
725 #[serde(default)]
726 pub server_injected_jitter_avg_ms: f64,
727 #[serde(default)]
729 pub server_reported_faults: u64,
730 #[serde(default)]
736 pub tcp_connect_samples: u64,
737 #[serde(default)]
738 pub tcp_connect_avg_ms: f64,
739 #[serde(default)]
740 pub tcp_connect_max_ms: f64,
741 #[serde(default)]
744 pub tls_handshake_samples: u64,
745 #[serde(default)]
746 pub tls_handshake_avg_ms: f64,
747 #[serde(default)]
748 pub tls_handshake_max_ms: f64,
749 #[serde(default)]
755 pub iterations_completed: u64,
756}
757
758impl K6Results {
759 pub fn error_rate(&self) -> f64 {
761 if self.total_requests == 0 {
762 return 0.0;
763 }
764 (self.failed_requests as f64 / self.total_requests as f64) * 100.0
765 }
766
767 pub fn success_rate(&self) -> f64 {
769 100.0 - self.error_rate()
770 }
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776
777 #[test]
778 fn test_k6_results_error_rate() {
779 let results = K6Results {
780 total_requests: 100,
781 failed_requests: 5,
782 avg_duration_ms: 100.0,
783 p95_duration_ms: 200.0,
784 p99_duration_ms: 300.0,
785 ..Default::default()
786 };
787
788 assert_eq!(results.error_rate(), 5.0);
789 assert_eq!(results.success_rate(), 95.0);
790 }
791
792 #[test]
793 fn test_k6_results_zero_requests() {
794 let results = K6Results::default();
795 assert_eq!(results.error_rate(), 0.0);
796 }
797
798 #[test]
799 fn discard_response_bodies_defaults_off_and_builder_flips_it() {
800 let exec = K6Executor {
804 k6_path: "k6".to_string(),
805 local_ips: String::new(),
806 discard_response_bodies: false,
807 dns_policy: String::new(),
808 force_http1: false,
809 capture_logs: true,
810 };
811 assert!(!exec.discard_response_bodies);
812 let exec = exec.with_discard_response_bodies(true);
813 assert!(exec.discard_response_bodies);
814 }
815
816 #[test]
817 fn dns_policy_defaults_empty_and_builder_sets_it() {
818 let exec = K6Executor {
822 k6_path: "k6".to_string(),
823 local_ips: String::new(),
824 discard_response_bodies: false,
825 dns_policy: String::new(),
826 force_http1: false,
827 capture_logs: true,
828 };
829 assert!(exec.dns_policy.is_empty());
830 let exec = exec.with_dns_policy("preferIPv6");
831 assert_eq!(exec.dns_policy, "preferIPv6");
832 }
833
834 #[test]
835 fn capture_logs_defaults_on_and_builder_disables() {
836 let exec = K6Executor {
840 k6_path: "k6".to_string(),
841 local_ips: String::new(),
842 discard_response_bodies: false,
843 dns_policy: String::new(),
844 force_http1: false,
845 capture_logs: true,
846 };
847 assert!(exec.capture_logs);
848 let exec = exec.with_capture_logs(false);
849 assert!(!exec.capture_logs);
850 }
851
852 #[test]
853 fn test_extract_failure_json_raw() {
854 let line = r#"MOCKFORGE_FAILURE:{"check":"test","expected":"status === 200"}"#;
855 let result = extract_failure_json(line).unwrap();
856 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
857 assert_eq!(parsed["check"], "test");
858 }
859
860 #[test]
861 fn test_extract_failure_json_logfmt() {
862 let line = r#"time="2026-01-01T00:00:00Z" level=info msg="MOCKFORGE_FAILURE:{\"check\":\"test\",\"response\":{\"body\":\"{\\\"key\\\":\\\"val\\\"}\"}} " source=console"#;
863 let result = extract_failure_json(line).unwrap();
864 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
865 assert_eq!(parsed["check"], "test");
866 assert_eq!(parsed["response"]["body"], r#"{"key":"val"}"#);
867 }
868
869 #[test]
870 fn test_extract_failure_json_no_marker() {
871 assert!(extract_failure_json("just a regular log line").is_none());
872 }
873
874 #[test]
880 fn test_extract_exchange_logfmt_with_backslash_escapes() {
881 let line = r#"time="2026-06-26T10:00:00Z" level=info msg="MOCKFORGE_EXCHANGE:{\"check\":\"u\",\"request\":{\"body\":\"--bnd\\r\\n\\u001a\"}}" source=console"#;
885 let result = extract_exchange_json(line).unwrap();
886 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
887 assert_eq!(parsed["check"], "u");
888 assert_eq!(parsed["request"]["body"], "--bnd\r\n\u{001a}");
891 }
892
893 #[test]
894 fn test_extract_exchange_raw_no_logfmt_wrapping() {
895 let line =
896 r#"MOCKFORGE_EXCHANGE:{"check":"x","request":{"body":""},"response":{"status":200}}"#;
897 let result = extract_exchange_json(line).unwrap();
898 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
899 assert_eq!(parsed["check"], "x");
900 assert_eq!(parsed["response"]["status"], 200);
901 }
902
903 #[test]
907 fn test_extract_exchange_logfmt_tolerates_extra_trailing_fields() {
908 let line = r#"msg="MOCKFORGE_EXCHANGE:{\"check\":\"t\"}" source=console vu=1 iter=0"#;
909 let result = extract_exchange_json(line).unwrap();
910 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
911 assert_eq!(parsed["check"], "t");
912 }
913
914 #[test]
918 fn test_extract_exchange_double_backslash_followed_by_quote() {
919 let line = r#"msg="MOCKFORGE_EXCHANGE:{\"k\":\"a\\\\\\\"x\\\"\"}" source=console"#;
922 let result = extract_exchange_json(line).unwrap();
923 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
924 assert_eq!(parsed["k"], r#"a\"x""#);
925 }
926
927 #[test]
928 fn merge_godebug_appends_http2client_off_and_honors_existing() {
929 assert_eq!(merge_godebug_http2client_off(None), "http2client=0");
930 assert_eq!(merge_godebug_http2client_off(Some("")), "http2client=0");
931 assert_eq!(merge_godebug_http2client_off(Some("netdns=go")), "netdns=go,http2client=0");
932 assert_eq!(
933 merge_godebug_http2client_off(Some("http2client=1")),
934 "http2client=1",
935 "an explicit http2client= in the environment wins"
936 );
937 assert_eq!(merge_godebug_http2client_off(Some("http2client=0")), "http2client=0");
938 }
939
940 #[test]
941 fn force_http1_builder_sets_godebug_env_value() {
942 let exec = K6Executor {
943 k6_path: "k6".to_string(),
944 local_ips: String::new(),
945 discard_response_bodies: false,
946 dns_policy: String::new(),
947 force_http1: false,
948 capture_logs: true,
949 };
950 assert!(exec.godebug_env_value().is_none());
951 let exec = exec.with_force_http1(true);
952 let value = exec.godebug_env_value().expect("force_http1 must set GODEBUG");
953 assert!(
954 value.split(',').any(|p| p.trim() == "http2client=0"),
955 "spawn env must contain http2client=0, got {value}"
956 );
957 }
958
959 #[test]
960 fn execute_with_port_applies_godebug_when_force_http1() {
961 let src = include_str!("executor.rs");
962 assert!(
963 src.contains("merge_godebug_http2client_off"),
964 "k6 spawn must merge GODEBUG=http2client=0 so Connection headers survive HTTPS ALPN"
965 );
966 assert!(
967 src.contains("if self.force_http1"),
968 "GODEBUG must be gated on force_http1, not applied to every k6 run"
969 );
970 }
971}