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}
164
165impl K6Executor {
166 pub fn new() -> Result<Self> {
168 let k6_path = which::which("k6")
169 .map_err(|_| BenchError::K6NotFound)?
170 .to_string_lossy()
171 .to_string();
172
173 Ok(Self {
174 k6_path,
175 local_ips: String::new(),
176 discard_response_bodies: false,
177 dns_policy: String::new(),
178 force_http1: false,
179 })
180 }
181
182 pub fn with_local_ips(mut self, local_ips: impl Into<String>) -> Self {
186 self.local_ips = local_ips.into();
187 self
188 }
189
190 pub fn with_discard_response_bodies(mut self, discard: bool) -> Self {
194 self.discard_response_bodies = discard;
195 self
196 }
197
198 pub fn with_dns_policy(mut self, policy: impl Into<String>) -> Self {
202 self.dns_policy = policy.into();
203 self
204 }
205
206 pub fn with_force_http1(mut self, force: bool) -> Self {
210 self.force_http1 = force;
211 self
212 }
213
214 #[cfg(test)]
217 pub(crate) fn godebug_env_value(&self) -> Option<String> {
218 if !self.force_http1 {
219 return None;
220 }
221 Some(merge_godebug_http2client_off(std::env::var("GODEBUG").ok().as_deref()))
222 }
223
224 pub fn is_k6_installed() -> bool {
226 which::which("k6").is_ok()
227 }
228
229 pub async fn warn_if_pre_v1() {
234 const DOCS: &str =
235 "See https://mockforge.dev/docs/reference/bench-capacity-sizing#tooling-requirements";
236 let output = match TokioCommand::new("k6").arg("version").output().await {
237 Ok(o) if o.status.success() => o,
238 _ => return,
239 };
240 let stdout = String::from_utf8_lossy(&output.stdout);
241 let Some(major) = stdout
242 .split_whitespace()
243 .find_map(|t| t.strip_prefix('v'))
244 .and_then(|rest| rest.split('.').next())
245 .and_then(|major| major.parse::<u64>().ok())
246 else {
247 return;
248 };
249 if major < 1 {
250 crate::reporter::TerminalReporter::print_warning(&format!(
251 "Detected k6 {}.x; mockforge bench requires k6 >= 1.0.0 \
252 (older versions break executors and summary parsing). Upgrade k6. {DOCS}",
253 major
254 ));
255 }
256 }
257
258 pub async fn get_version(&self) -> Result<String> {
260 let output = TokioCommand::new(&self.k6_path)
261 .arg("version")
262 .output()
263 .await
264 .map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
265
266 if !output.status.success() {
267 return Err(BenchError::K6ExecutionFailed("Failed to get k6 version".to_string()));
268 }
269
270 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
271 }
272
273 pub async fn execute(
283 &self,
284 script_path: &Path,
285 output_dir: Option<&Path>,
286 verbose: bool,
287 ) -> Result<K6Results> {
288 self.execute_with_port(script_path, output_dir, verbose, None).await
289 }
290
291 pub async fn execute_with_port(
293 &self,
294 script_path: &Path,
295 output_dir: Option<&Path>,
296 verbose: bool,
297 api_port: Option<u16>,
298 ) -> Result<K6Results> {
299 println!("Starting load test...\n");
300
301 let mut cmd = TokioCommand::new(&self.k6_path);
302 cmd.arg("run");
303
304 if let Some(port) = api_port {
307 cmd.arg("--address").arg(format!("localhost:{}", port));
308 }
309
310 if !self.local_ips.is_empty() {
315 cmd.arg("--local-ips").arg(&self.local_ips);
316 }
317
318 if self.discard_response_bodies {
321 cmd.env("K6_DISCARD_RESPONSE_BODIES", "true");
322 }
323
324 if self.force_http1 {
328 let existing = std::env::var("GODEBUG").ok();
329 cmd.env("GODEBUG", merge_godebug_http2client_off(existing.as_deref()));
330 }
331
332 if !self.dns_policy.is_empty() {
337 cmd.arg("--dns").arg(format!("policy={}", self.dns_policy));
338 }
339
340 if verbose {
347 cmd.arg("--verbose");
348 }
349
350 let abs_script =
352 std::fs::canonicalize(script_path).unwrap_or_else(|_| script_path.to_path_buf());
353 cmd.arg(&abs_script);
354
355 if let Some(dir) = output_dir {
358 cmd.current_dir(dir);
359 }
360
361 cmd.stdout(Stdio::piped());
362 cmd.stderr(Stdio::piped());
363
364 let mut child = cmd.spawn().map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
365
366 let stdout = child
367 .stdout
368 .take()
369 .ok_or_else(|| BenchError::K6ExecutionFailed("Failed to capture stdout".to_string()))?;
370
371 let stderr = child
372 .stderr
373 .take()
374 .ok_or_else(|| BenchError::K6ExecutionFailed("Failed to capture stderr".to_string()))?;
375
376 let stdout_reader = BufReader::new(stdout);
378 let stderr_reader = BufReader::new(stderr);
379
380 let mut stdout_lines = stdout_reader.lines();
381 let mut stderr_lines = stderr_reader.lines();
382
383 let spinner = ProgressBar::new_spinner();
385 spinner.set_style(
386 ProgressStyle::default_spinner().template("{spinner:.green} {msg}").unwrap(),
387 );
388 spinner.set_message("Running load test...");
389
390 let failure_details: Arc<tokio::sync::Mutex<Vec<String>>> =
393 Arc::new(tokio::sync::Mutex::new(Vec::new()));
394 let fd_stdout = Arc::clone(&failure_details);
395 let fd_stderr = Arc::clone(&failure_details);
396
397 let exchange_details: Arc<tokio::sync::Mutex<Vec<String>>> =
399 Arc::new(tokio::sync::Mutex::new(Vec::new()));
400 let ex_stdout = Arc::clone(&exchange_details);
401 let ex_stderr = Arc::clone(&exchange_details);
402
403 let network_events: Arc<tokio::sync::Mutex<Vec<String>>> =
408 Arc::new(tokio::sync::Mutex::new(Vec::new()));
409 let ne_stdout = Arc::clone(&network_events);
410 let ne_stderr = Arc::clone(&network_events);
411
412 let log_lines: Arc<tokio::sync::Mutex<Vec<String>>> =
414 Arc::new(tokio::sync::Mutex::new(Vec::new()));
415 let log_stdout = Arc::clone(&log_lines);
416 let log_stderr = Arc::clone(&log_lines);
417
418 let stdout_handle = tokio::spawn(async move {
420 while let Ok(Some(line)) = stdout_lines.next_line().await {
421 log_stdout.lock().await.push(format!("[stdout] {}", line));
422 if let Some(json_str) = extract_failure_json(&line) {
423 fd_stdout.lock().await.push(json_str);
424 } else if let Some(json_str) = extract_exchange_json(&line) {
425 ex_stdout.lock().await.push(json_str);
426 } else if let Some(json_str) = extract_network_event_json(&line) {
427 ne_stdout.lock().await.push(json_str);
428 } else {
429 spinner.set_message(line.clone());
430 if !line.is_empty() && !line.contains("running") && !line.contains("default") {
431 println!("{}", line);
432 }
433 }
434 }
435 spinner.finish_and_clear();
436 });
437
438 let stderr_handle = tokio::spawn(async move {
440 while let Ok(Some(line)) = stderr_lines.next_line().await {
441 if !line.is_empty() {
442 log_stderr.lock().await.push(format!("[stderr] {}", line));
443 if let Some(json_str) = extract_failure_json(&line) {
444 fd_stderr.lock().await.push(json_str);
445 } else if let Some(json_str) = extract_exchange_json(&line) {
446 ex_stderr.lock().await.push(json_str);
447 } else if let Some(json_str) = extract_network_event_json(&line) {
448 ne_stderr.lock().await.push(json_str);
449 } else {
450 eprintln!("{}", line);
451 }
452 }
453 }
454 });
455
456 let status =
458 child.wait().await.map_err(|e| BenchError::K6ExecutionFailed(e.to_string()))?;
459
460 let _ = stdout_handle.await;
462 let _ = stderr_handle.await;
463
464 let exit_code = status.code().unwrap_or(-1);
467 if !status.success() && exit_code != 99 {
468 return Err(BenchError::K6ExecutionFailed(format!(
469 "k6 exited with status: {}",
470 status
471 )));
472 }
473 if exit_code == 99 {
474 tracing::warn!("k6 thresholds crossed (exit code 99) — results will still be parsed");
475 }
476
477 if let Some(dir) = output_dir {
479 let details = failure_details.lock().await;
480 if !details.is_empty() {
481 let failure_path = dir.join("conformance-failure-details.json");
482 let parsed: Vec<serde_json::Value> =
483 details.iter().filter_map(|s| serde_json::from_str(s).ok()).collect();
484 if let Ok(json) = serde_json::to_string_pretty(&parsed) {
485 let _ = std::fs::write(&failure_path, json);
486 }
487 }
488
489 let exchanges = exchange_details.lock().await;
491 if !exchanges.is_empty() {
492 let exchange_path = dir.join("conformance-requests.json");
493 let parsed: Vec<serde_json::Value> =
494 exchanges.iter().filter_map(|s| serde_json::from_str(s).ok()).collect();
495 if let Ok(json) = serde_json::to_string_pretty(&parsed) {
496 let _ = std::fs::write(&exchange_path, json);
497 tracing::info!(
498 "Exported {} request/response pairs to {}",
499 parsed.len(),
500 exchange_path.display()
501 );
502 }
503 }
504
505 let net_events = network_events.lock().await;
510 let net_path = dir.join("conformance-network-events.json");
511 let parsed: Vec<serde_json::Value> =
512 net_events.iter().filter_map(|s| serde_json::from_str(s).ok()).collect();
513 if let Ok(json) = serde_json::to_string_pretty(&parsed) {
514 let _ = std::fs::write(&net_path, json);
515 if !parsed.is_empty() {
516 tracing::warn!(
517 "Recorded {} wire-level network event(s) to {}",
518 parsed.len(),
519 net_path.display()
520 );
521 }
522 }
523
524 let lines = log_lines.lock().await;
526 if !lines.is_empty() {
527 let log_path = dir.join("k6-output.log");
528 let _ = std::fs::write(&log_path, lines.join("\n"));
529 println!("k6 output log saved to: {}", log_path.display());
530 }
531 }
532
533 let results = if let Some(dir) = output_dir {
535 Self::parse_results(dir)?
536 } else {
537 K6Results::default()
538 };
539
540 Ok(results)
541 }
542
543 fn parse_results(output_dir: &Path) -> Result<K6Results> {
545 let summary_path = output_dir.join("summary.json");
546
547 if !summary_path.exists() {
548 return Ok(K6Results::default());
549 }
550
551 let content = std::fs::read_to_string(summary_path)
552 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
553
554 let json: serde_json::Value = serde_json::from_str(&content)
555 .map_err(|e| BenchError::ResultsParseError(e.to_string()))?;
556
557 let duration_values = &json["metrics"]["http_req_duration"]["values"];
558
559 let server_latency = &json["metrics"]["mockforge_server_injected_latency_ms"]["values"];
560 let server_jitter = &json["metrics"]["mockforge_server_injected_jitter_ms"]["values"];
561 let server_fault = &json["metrics"]["mockforge_server_fault_total"]["values"]["count"];
562
563 let tcp_connecting = &json["metrics"]["http_req_connecting"]["values"];
574 let tls_handshake = &json["metrics"]["http_req_tls_handshaking"]["values"];
575 let mf_conns_opened = &json["metrics"]["mockforge_connections_opened"]["values"]["count"];
576
577 Ok(K6Results {
578 total_requests: json["metrics"]["http_reqs"]["values"]["count"].as_u64().unwrap_or(0),
579 failed_requests: json["metrics"]["http_req_failed"]["values"]["passes"]
583 .as_u64()
584 .unwrap_or(0),
585 avg_duration_ms: duration_values["avg"].as_f64().unwrap_or(0.0),
586 p95_duration_ms: duration_values["p(95)"].as_f64().unwrap_or(0.0),
587 p99_duration_ms: duration_values["p(99)"].as_f64().unwrap_or(0.0),
588 rps: json["metrics"]["http_reqs"]["values"]["rate"].as_f64().unwrap_or(0.0),
589 vus_max: json["metrics"]["vus_max"]["values"]["value"].as_u64().unwrap_or(0) as u32,
590 min_duration_ms: duration_values["min"].as_f64().unwrap_or(0.0),
591 max_duration_ms: duration_values["max"].as_f64().unwrap_or(0.0),
592 med_duration_ms: duration_values["med"].as_f64().unwrap_or(0.0),
593 p90_duration_ms: duration_values["p(90)"].as_f64().unwrap_or(0.0),
594 server_injected_latency_samples: server_latency["count"].as_u64().unwrap_or(0),
595 server_injected_latency_avg_ms: server_latency["avg"].as_f64().unwrap_or(0.0),
596 server_injected_latency_max_ms: server_latency["max"].as_f64().unwrap_or(0.0),
597 server_injected_jitter_samples: server_jitter["count"].as_u64().unwrap_or(0),
598 server_injected_jitter_avg_ms: server_jitter["avg"].as_f64().unwrap_or(0.0),
599 server_reported_faults: server_fault.as_u64().unwrap_or(0),
600 tcp_connect_samples: mf_conns_opened.as_u64().unwrap_or(0),
603 tcp_connect_avg_ms: tcp_connecting["avg"].as_f64().unwrap_or(0.0),
604 tcp_connect_max_ms: tcp_connecting["max"].as_f64().unwrap_or(0.0),
605 tls_handshake_samples: if tls_handshake["avg"].as_f64().unwrap_or(0.0) > 0.0 {
607 mf_conns_opened.as_u64().unwrap_or(0)
610 } else {
611 0
612 },
613 tls_handshake_avg_ms: tls_handshake["avg"].as_f64().unwrap_or(0.0),
614 tls_handshake_max_ms: tls_handshake["max"].as_f64().unwrap_or(0.0),
615 iterations_completed: json["metrics"]["iterations"]["values"]["count"]
616 .as_u64()
617 .unwrap_or(0),
618 })
619 }
620}
621
622impl Default for K6Executor {
623 fn default() -> Self {
624 Self::new().expect("k6 not found")
625 }
626}
627
628#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
630pub struct K6Results {
631 pub total_requests: u64,
632 pub failed_requests: u64,
633 pub avg_duration_ms: f64,
634 pub p95_duration_ms: f64,
635 pub p99_duration_ms: f64,
636 pub rps: f64,
637 pub vus_max: u32,
638 pub min_duration_ms: f64,
639 pub max_duration_ms: f64,
640 pub med_duration_ms: f64,
641 pub p90_duration_ms: f64,
642 #[serde(default)]
647 pub server_injected_latency_samples: u64,
648 #[serde(default)]
649 pub server_injected_latency_avg_ms: f64,
650 #[serde(default)]
651 pub server_injected_latency_max_ms: f64,
652 #[serde(default)]
653 pub server_injected_jitter_samples: u64,
654 #[serde(default)]
655 pub server_injected_jitter_avg_ms: f64,
656 #[serde(default)]
658 pub server_reported_faults: u64,
659 #[serde(default)]
665 pub tcp_connect_samples: u64,
666 #[serde(default)]
667 pub tcp_connect_avg_ms: f64,
668 #[serde(default)]
669 pub tcp_connect_max_ms: f64,
670 #[serde(default)]
673 pub tls_handshake_samples: u64,
674 #[serde(default)]
675 pub tls_handshake_avg_ms: f64,
676 #[serde(default)]
677 pub tls_handshake_max_ms: f64,
678 #[serde(default)]
684 pub iterations_completed: u64,
685}
686
687impl K6Results {
688 pub fn error_rate(&self) -> f64 {
690 if self.total_requests == 0 {
691 return 0.0;
692 }
693 (self.failed_requests as f64 / self.total_requests as f64) * 100.0
694 }
695
696 pub fn success_rate(&self) -> f64 {
698 100.0 - self.error_rate()
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 #[test]
707 fn test_k6_results_error_rate() {
708 let results = K6Results {
709 total_requests: 100,
710 failed_requests: 5,
711 avg_duration_ms: 100.0,
712 p95_duration_ms: 200.0,
713 p99_duration_ms: 300.0,
714 ..Default::default()
715 };
716
717 assert_eq!(results.error_rate(), 5.0);
718 assert_eq!(results.success_rate(), 95.0);
719 }
720
721 #[test]
722 fn test_k6_results_zero_requests() {
723 let results = K6Results::default();
724 assert_eq!(results.error_rate(), 0.0);
725 }
726
727 #[test]
728 fn discard_response_bodies_defaults_off_and_builder_flips_it() {
729 let exec = K6Executor {
733 k6_path: "k6".to_string(),
734 local_ips: String::new(),
735 discard_response_bodies: false,
736 dns_policy: String::new(),
737 force_http1: false,
738 };
739 assert!(!exec.discard_response_bodies);
740 let exec = exec.with_discard_response_bodies(true);
741 assert!(exec.discard_response_bodies);
742 }
743
744 #[test]
745 fn dns_policy_defaults_empty_and_builder_sets_it() {
746 let exec = K6Executor {
750 k6_path: "k6".to_string(),
751 local_ips: String::new(),
752 discard_response_bodies: false,
753 dns_policy: String::new(),
754 force_http1: false,
755 };
756 assert!(exec.dns_policy.is_empty());
757 let exec = exec.with_dns_policy("preferIPv6");
758 assert_eq!(exec.dns_policy, "preferIPv6");
759 }
760
761 #[test]
762 fn test_extract_failure_json_raw() {
763 let line = r#"MOCKFORGE_FAILURE:{"check":"test","expected":"status === 200"}"#;
764 let result = extract_failure_json(line).unwrap();
765 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
766 assert_eq!(parsed["check"], "test");
767 }
768
769 #[test]
770 fn test_extract_failure_json_logfmt() {
771 let line = r#"time="2026-01-01T00:00:00Z" level=info msg="MOCKFORGE_FAILURE:{\"check\":\"test\",\"response\":{\"body\":\"{\\\"key\\\":\\\"val\\\"}\"}} " source=console"#;
772 let result = extract_failure_json(line).unwrap();
773 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
774 assert_eq!(parsed["check"], "test");
775 assert_eq!(parsed["response"]["body"], r#"{"key":"val"}"#);
776 }
777
778 #[test]
779 fn test_extract_failure_json_no_marker() {
780 assert!(extract_failure_json("just a regular log line").is_none());
781 }
782
783 #[test]
789 fn test_extract_exchange_logfmt_with_backslash_escapes() {
790 let line = r#"time="2026-06-26T10:00:00Z" level=info msg="MOCKFORGE_EXCHANGE:{\"check\":\"u\",\"request\":{\"body\":\"--bnd\\r\\n\\u001a\"}}" source=console"#;
794 let result = extract_exchange_json(line).unwrap();
795 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
796 assert_eq!(parsed["check"], "u");
797 assert_eq!(parsed["request"]["body"], "--bnd\r\n\u{001a}");
800 }
801
802 #[test]
803 fn test_extract_exchange_raw_no_logfmt_wrapping() {
804 let line =
805 r#"MOCKFORGE_EXCHANGE:{"check":"x","request":{"body":""},"response":{"status":200}}"#;
806 let result = extract_exchange_json(line).unwrap();
807 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
808 assert_eq!(parsed["check"], "x");
809 assert_eq!(parsed["response"]["status"], 200);
810 }
811
812 #[test]
816 fn test_extract_exchange_logfmt_tolerates_extra_trailing_fields() {
817 let line = r#"msg="MOCKFORGE_EXCHANGE:{\"check\":\"t\"}" source=console vu=1 iter=0"#;
818 let result = extract_exchange_json(line).unwrap();
819 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
820 assert_eq!(parsed["check"], "t");
821 }
822
823 #[test]
827 fn test_extract_exchange_double_backslash_followed_by_quote() {
828 let line = r#"msg="MOCKFORGE_EXCHANGE:{\"k\":\"a\\\\\\\"x\\\"\"}" source=console"#;
831 let result = extract_exchange_json(line).unwrap();
832 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
833 assert_eq!(parsed["k"], r#"a\"x""#);
834 }
835
836 #[test]
837 fn merge_godebug_appends_http2client_off_and_honors_existing() {
838 assert_eq!(merge_godebug_http2client_off(None), "http2client=0");
839 assert_eq!(merge_godebug_http2client_off(Some("")), "http2client=0");
840 assert_eq!(merge_godebug_http2client_off(Some("netdns=go")), "netdns=go,http2client=0");
841 assert_eq!(
842 merge_godebug_http2client_off(Some("http2client=1")),
843 "http2client=1",
844 "an explicit http2client= in the environment wins"
845 );
846 assert_eq!(merge_godebug_http2client_off(Some("http2client=0")), "http2client=0");
847 }
848
849 #[test]
850 fn force_http1_builder_sets_godebug_env_value() {
851 let exec = K6Executor {
852 k6_path: "k6".to_string(),
853 local_ips: String::new(),
854 discard_response_bodies: false,
855 dns_policy: String::new(),
856 force_http1: false,
857 };
858 assert!(exec.godebug_env_value().is_none());
859 let exec = exec.with_force_http1(true);
860 let value = exec.godebug_env_value().expect("force_http1 must set GODEBUG");
861 assert!(
862 value.split(',').any(|p| p.trim() == "http2client=0"),
863 "spawn env must contain http2client=0, got {value}"
864 );
865 }
866
867 #[test]
868 fn execute_with_port_applies_godebug_when_force_http1() {
869 let src = include_str!("executor.rs");
870 assert!(
871 src.contains("merge_godebug_http2client_off"),
872 "k6 spawn must merge GODEBUG=http2client=0 so Connection headers survive HTTPS ALPN"
873 );
874 assert!(
875 src.contains("if self.force_http1"),
876 "GODEBUG must be gated on force_http1, not applied to every k6 run"
877 );
878 }
879}