1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use anyhow::Context;
5
6use crate::Result;
7use crate::cli_util;
8use crate::suite::auth::{AuthInit, SuiteAuthManager};
9use crate::suite::cleanup::{CleanupContext, run_case_cleanup};
10use crate::suite::filter::selection_skip_reason;
11use crate::suite::resolve::write_file;
12use crate::suite::results::{SuiteCaseResult, SuiteRunResults, SuiteRunSummary};
13use crate::suite::runtime::{
14 path_relative_to_repo_or_abs, resolve_effective_env, resolve_effective_no_history,
15 resolve_rest_base_url, sanitize_id, time_iso_now, time_run_id_now,
16};
17use crate::suite::schema::LoadedSuite;
18
19mod context;
20mod graphql;
21mod grpc;
22mod progress;
23mod rest;
24
25pub use context::{SuiteRunOptions, SuiteRunOutput};
26
27fn mask_args_for_command_snippet(args: &[String]) -> String {
28 if args.is_empty() {
29 return String::new();
30 }
31
32 let mut out: Vec<String> = Vec::new();
33 let mut mask_next = false;
34 for a in args {
35 if mask_next {
36 out.push("REDACTED".to_string());
37 mask_next = false;
38 continue;
39 }
40 if a == "--token" || a == "--jwt" {
41 out.push(a.clone());
42 mask_next = true;
43 continue;
44 }
45 if let Some((k, _v)) = a.split_once('=')
46 && (k == "--token" || k == "--jwt")
47 {
48 out.push(format!("{k}=REDACTED"));
49 continue;
50 }
51 out.push(a.clone());
52 }
53
54 out.into_iter()
55 .map(|a| cli_util::shell_quote(&a))
56 .collect::<Vec<_>>()
57 .join(" ")
58}
59
60pub fn run_suite(
61 repo_root: &Path,
62 loaded: LoadedSuite,
63 mut options: SuiteRunOptions,
64) -> Result<SuiteRunOutput> {
65 let mut progress = progress::SuiteProgress::new(options.progress.take());
66
67 let run_id = time_run_id_now()?;
68 let started_at = time_iso_now()?;
69
70 let run_dir_abs = options.output_dir_base.join(&run_id);
71 std::fs::create_dir_all(&run_dir_abs)
72 .with_context(|| format!("create output dir: {}", run_dir_abs.display()))?;
73
74 let suite_file_rel = path_relative_to_repo_or_abs(repo_root, &loaded.suite_path);
75 let output_dir_rel = path_relative_to_repo_or_abs(repo_root, &run_dir_abs);
76 let suite_name_value = context::suite_display_name(&loaded);
77
78 let defaults = &loaded.manifest.defaults;
79
80 let mut auth_init_message: Option<String> = None;
81 let mut auth_manager: Option<SuiteAuthManager> = match loaded.manifest.auth.clone() {
82 None => None,
83 Some(auth) => match SuiteAuthManager::init_from_suite(auth, defaults)? {
84 AuthInit::Disabled { message } => {
85 auth_init_message = message;
86 None
87 }
88 AuthInit::Enabled(mgr) => Some(*mgr),
89 },
90 };
91
92 let mut total: u32 = 0;
93 let mut passed: u32 = 0;
94 let mut failed: u32 = 0;
95 let mut skipped: u32 = 0;
96
97 let mut cases_out: Vec<SuiteCaseResult> = Vec::new();
98
99 let mut case_index: u64 = 0;
100
101 for c in &loaded.manifest.cases {
102 total += 1;
103
104 let id = c.id.trim().to_string();
105 let safe_id = sanitize_id(&id);
106
107 case_index = case_index.saturating_add(1);
108 progress.on_case_start(case_index, if id.is_empty() { &safe_id } else { &id });
109
110 let tags = c.tags.clone();
111 let ty = context::case_type_normalized(&c.case_type);
112
113 let effective_env = resolve_effective_env(&c.env, defaults);
114 let effective_no_history = resolve_effective_no_history(c.no_history, defaults);
115
116 if let Some(reason) = selection_skip_reason(
117 &id,
118 &tags,
119 &options.required_tags,
120 &options.only_ids,
121 &options.skip_ids,
122 ) {
123 skipped += 1;
124 cases_out.push(SuiteCaseResult {
125 id,
126 case_type: ty,
127 status: "skipped".to_string(),
128 duration_ms: 0,
129 tags,
130 command: None,
131 message: Some(reason.as_str().to_string()),
132 assertions: None,
133 stdout_file: None,
134 stderr_file: None,
135 });
136 continue;
137 }
138
139 let start = Instant::now();
140
141 let mut status: String;
142 let mut message: Option<String> = None;
143 let mut assertions: Option<serde_json::Value> = None;
144 let mut command_snippet: Option<String> = None;
145 let mut stdout_file_abs: Option<PathBuf> = None;
146 let mut stderr_file_abs: Option<PathBuf> = None;
147
148 let mut rest_config_dir = String::new();
149 let mut rest_url = String::new();
150 let mut rest_token = String::new();
151 let mut gql_config_dir = String::new();
152 let mut gql_url = String::new();
153 let mut gql_jwt = String::new();
154 let mut access_token_for_case = String::new();
155
156 match ty.as_str() {
157 "rest" => {
158 match rest::prepare_rest_case(
159 repo_root,
160 c,
161 &id,
162 defaults,
163 &options.env_rest_url,
164 &options.env_gql_url,
165 options.allow_writes_flag,
166 &effective_env,
167 auth_manager.as_mut(),
168 )? {
169 rest::PrepareOutcome::Ready(plan) => {
170 let rest::RestCasePlan {
171 request_abs,
172 request_file,
173 config_dir,
174 url,
175 token,
176 access_token_for_case: prepared_access_token,
177 } = plan;
178
179 rest_config_dir = config_dir;
180 rest_url = url;
181 rest_token = token;
182 access_token_for_case = prepared_access_token;
183
184 let out = rest::run_rest_case(
185 repo_root,
186 &run_dir_abs,
187 &safe_id,
188 effective_no_history,
189 &effective_env,
190 defaults,
191 &options.env_rest_url,
192 &rest_config_dir,
193 &rest_url,
194 &rest_token,
195 &access_token_for_case,
196 &request_abs,
197 &request_file,
198 )?;
199
200 status = out.status;
201 message = out.message;
202 command_snippet = out.command_snippet;
203 stdout_file_abs = Some(out.stdout_path);
204 stderr_file_abs = Some(out.stderr_path);
205
206 match status.as_str() {
207 "passed" => passed += 1,
208 "failed" => failed += 1,
209 _ => {}
210 }
211 }
212 rest::PrepareOutcome::Skipped { message: msg } => {
213 status = "skipped".to_string();
214 message = Some(msg);
215 skipped += 1;
216 }
217 rest::PrepareOutcome::Failed { message: msg } => {
218 status = "failed".to_string();
219 message = Some(msg);
220 failed += 1;
221 }
222 }
223 }
224 "rest-flow" | "rest_flow" => {
225 match rest::prepare_rest_flow_case(
226 repo_root,
227 c,
228 &id,
229 defaults,
230 &options.env_rest_url,
231 options.allow_writes_flag,
232 &effective_env,
233 )? {
234 rest::PrepareOutcome::Ready(plan) => {
235 let login_abs = plan.login_abs;
236 let request_abs = plan.request_abs;
237 let login_request_file = plan.login_request_file;
238 let main_request_file = plan.main_request_file;
239 rest_config_dir = plan.config_dir;
240 rest_url = plan.url;
241 let token_jq = plan.token_jq;
242
243 let stderr_path = run_dir_abs.join(format!("{safe_id}.stderr.log"));
244 write_file(&stderr_path, b"")?;
245 stderr_file_abs = Some(stderr_path.clone());
246
247 let base_url = match resolve_rest_base_url(
248 repo_root,
249 &rest_config_dir,
250 &rest_url,
251 &effective_env,
252 defaults,
253 &options.env_rest_url,
254 ) {
255 Ok(v) => v,
256 Err(err) => {
257 write_file(&stderr_path, format!("{err:#}\n").as_bytes())?;
258 status = "failed".to_string();
259 message = Some("rest_flow_login_failed".to_string());
260 failed += 1;
261 cases_out.push(case_result(
262 &id,
263 &ty,
264 &tags,
265 &status,
266 start.elapsed(),
267 command_snippet.clone(),
268 message.clone(),
269 assertions.clone(),
270 None,
271 Some(&stderr_path),
272 repo_root,
273 ));
274 if options.fail_fast && status == "failed" {
275 break;
276 }
277 continue;
278 }
279 };
280
281 let mut login_args: Vec<String> = Vec::new();
283 login_args.push("call".to_string());
284 login_args.push("--config-dir".to_string());
285 login_args.push(rest_config_dir.clone());
286 if effective_no_history {
287 login_args.push("--no-history".to_string());
288 }
289 if !rest_url.trim().is_empty() {
290 login_args.push("--url".to_string());
291 login_args.push(rest_url.clone());
292 } else if !effective_env.trim().is_empty() {
293 login_args.push("--env".to_string());
294 login_args.push(effective_env.clone());
295 }
296 login_args.push(path_relative_to_repo_or_abs(repo_root, &login_abs));
297
298 let mut main_args: Vec<String> = Vec::new();
299 main_args.push("call".to_string());
300 main_args.push("--config-dir".to_string());
301 main_args.push(rest_config_dir.clone());
302 if effective_no_history {
303 main_args.push("--no-history".to_string());
304 }
305 if !rest_url.trim().is_empty() {
306 main_args.push("--url".to_string());
307 main_args.push(rest_url.clone());
308 } else if !effective_env.trim().is_empty() {
309 main_args.push("--env".to_string());
310 main_args.push(effective_env.clone());
311 }
312 main_args.push(path_relative_to_repo_or_abs(repo_root, &request_abs));
313
314 let login_args_snip = mask_args_for_command_snippet(&login_args);
315 let main_args_snip = mask_args_for_command_snippet(&main_args);
316 let token_expr_q = cli_util::shell_quote(&token_jq);
317 command_snippet = Some(format!(
318 "ACCESS_TOKEN=\"$(REST_TOKEN_NAME= ACCESS_TOKEN= {} {} | jq -r {token_expr_q})\" REST_TOKEN_NAME= {} {}",
319 cli_util::shell_quote("api-rest"),
320 login_args_snip,
321 cli_util::shell_quote("api-rest"),
322 main_args_snip
323 ));
324
325 let login_executed = match crate::rest::runner::execute_rest_request(
327 &login_request_file,
328 &base_url,
329 None,
330 ) {
331 Ok(v) => v,
332 Err(err) => {
333 write_file(&stderr_path, format!("{err:#}\n").as_bytes())?;
334 status = "failed".to_string();
335 message = Some("rest_flow_login_failed".to_string());
336 failed += 1;
337 cases_out.push(case_result(
338 &id,
339 &ty,
340 &tags,
341 &status,
342 start.elapsed(),
343 command_snippet,
344 message,
345 assertions,
346 None,
347 Some(&stderr_path),
348 repo_root,
349 ));
350 if options.fail_fast && status == "failed" {
351 break;
352 }
353 continue;
354 }
355 };
356
357 if let Err(err) = crate::rest::expect::evaluate_main_response(
358 &login_request_file.request,
359 &login_executed,
360 ) {
361 write_file(&stderr_path, format!("{err:#}\n").as_bytes())?;
362 status = "failed".to_string();
363 message = Some("rest_flow_login_failed".to_string());
364 failed += 1;
365 cases_out.push(case_result(
366 &id,
367 &ty,
368 &tags,
369 &status,
370 start.elapsed(),
371 command_snippet,
372 message,
373 assertions,
374 None,
375 Some(&stderr_path),
376 repo_root,
377 ));
378 if options.fail_fast && status == "failed" {
379 break;
380 }
381 continue;
382 }
383
384 let login_json: serde_json::Value =
385 match serde_json::from_slice(&login_executed.response.body) {
386 Ok(v) => v,
387 Err(_) => serde_json::Value::Null,
388 };
389 let token = crate::jq::query_raw(&login_json, &token_jq)
390 .ok()
391 .and_then(|lines| lines.into_iter().next())
392 .unwrap_or_default();
393 let token = token.trim().to_string();
394 if token.is_empty() || token == "null" {
395 let hint = "Failed to extract token from login response.\nHint: set cases[i].tokenJq to the token field (e.g. .accessToken).\n";
396 write_file(&stderr_path, hint.as_bytes())?;
397 status = "failed".to_string();
398 message = Some("rest_flow_token_extract_failed".to_string());
399 failed += 1;
400 cases_out.push(case_result(
401 &id,
402 &ty,
403 &tags,
404 &status,
405 start.elapsed(),
406 command_snippet,
407 message,
408 assertions,
409 None,
410 Some(&stderr_path),
411 repo_root,
412 ));
413 if options.fail_fast && status == "failed" {
414 break;
415 }
416 continue;
417 }
418
419 let stdout_path = run_dir_abs.join(format!("{safe_id}.response.json"));
421 write_file(&stdout_path, b"")?;
422 stdout_file_abs = Some(stdout_path.clone());
423
424 match crate::rest::runner::execute_rest_request(
425 &main_request_file,
426 &base_url,
427 Some(&token),
428 ) {
429 Ok(executed) => {
430 write_file(&stdout_path, &executed.response.body)?;
431 if let Err(err) = crate::rest::expect::evaluate_main_response(
432 &main_request_file.request,
433 &executed,
434 ) {
435 write_file(&stderr_path, format!("{err:#}\n").as_bytes())?;
436 status = "failed".to_string();
437 message = Some("rest_flow_request_failed".to_string());
438 failed += 1;
439 } else {
440 status = "passed".to_string();
441 passed += 1;
442 }
443 }
444 Err(err) => {
445 write_file(&stderr_path, format!("{err:#}\n").as_bytes())?;
446 status = "failed".to_string();
447 message = Some("rest_flow_request_failed".to_string());
448 failed += 1;
449 }
450 }
451 }
452 rest::PrepareOutcome::Skipped { message: msg } => {
453 status = "skipped".to_string();
454 message = Some(msg);
455 skipped += 1;
456 }
457 rest::PrepareOutcome::Failed { message: msg } => {
458 status = "failed".to_string();
459 message = Some(msg);
460 failed += 1;
461 }
462 }
463 }
464 "graphql" => {
465 match graphql::prepare_graphql_case(
466 repo_root,
467 c,
468 &id,
469 defaults,
470 &options.env_rest_url,
471 &options.env_gql_url,
472 options.allow_writes_flag,
473 &effective_env,
474 auth_manager.as_mut(),
475 )? {
476 graphql::PrepareOutcome::Ready(plan) => {
477 let graphql::GraphqlCasePlan {
478 op_abs,
479 vars_abs,
480 config_dir,
481 url,
482 jwt,
483 access_token_for_case: prepared_access_token,
484 } = plan;
485
486 gql_config_dir = config_dir;
487 gql_url = url;
488 gql_jwt = jwt;
489 access_token_for_case = prepared_access_token;
490
491 let expect_jq_raw = c.expect.as_ref().map(|e| e.jq.as_str()).unwrap_or("");
492 let graphql::GraphqlCaseRunOutput {
493 status: graphql_status,
494 message: graphql_message,
495 assertions: graphql_assertions,
496 command_snippet: graphql_command_snippet,
497 stdout_path,
498 stderr_path,
499 skip_cleanup,
500 } = graphql::run_graphql_case(
501 repo_root,
502 &run_dir_abs,
503 &safe_id,
504 effective_no_history,
505 &effective_env,
506 defaults,
507 &options.env_gql_url,
508 &gql_config_dir,
509 &gql_url,
510 &gql_jwt,
511 &access_token_for_case,
512 &op_abs,
513 vars_abs.as_deref(),
514 c.allow_errors,
515 expect_jq_raw,
516 )?;
517
518 status = graphql_status;
519 message = graphql_message;
520 assertions = graphql_assertions;
521 command_snippet = graphql_command_snippet;
522 stdout_file_abs = Some(stdout_path);
523 stderr_file_abs = Some(stderr_path);
524
525 match status.as_str() {
526 "passed" => passed += 1,
527 "failed" => failed += 1,
528 _ => {}
529 }
530
531 if skip_cleanup {
532 cases_out.push(case_result(
533 &id,
534 &ty,
535 &tags,
536 &status,
537 start.elapsed(),
538 command_snippet.clone(),
539 message.clone(),
540 assertions.clone(),
541 stdout_file_abs.as_deref(),
542 stderr_file_abs.as_deref(),
543 repo_root,
544 ));
545
546 if options.fail_fast && status == "failed" {
547 break;
548 }
549 continue;
550 }
551 }
552 graphql::PrepareOutcome::Skipped { message: msg } => {
553 status = "skipped".to_string();
554 message = Some(msg);
555 skipped += 1;
556 }
557 graphql::PrepareOutcome::Failed { message: msg } => {
558 status = "failed".to_string();
559 message = Some(msg);
560 failed += 1;
561 }
562 }
563 }
564 "grpc" => match grpc::prepare_grpc_case(repo_root, c, &id, defaults)? {
565 grpc::PrepareOutcome::Ready(plan) => {
566 let grpc::GrpcCasePlan {
567 request_abs,
568 request_file,
569 config_dir,
570 url,
571 token,
572 } = plan;
573 let grpc_config_dir = config_dir;
574 let grpc_url = url;
575 let grpc_token = token;
576
577 let grpc::GrpcCaseRunOutput {
578 status: grpc_status,
579 message: grpc_message,
580 assertions: grpc_assertions,
581 command_snippet: grpc_command_snippet,
582 stdout_path,
583 stderr_path,
584 } = grpc::run_grpc_case(
585 repo_root,
586 &run_dir_abs,
587 &safe_id,
588 effective_no_history,
589 &effective_env,
590 defaults,
591 &options.env_grpc_url,
592 &grpc_config_dir,
593 &grpc_url,
594 &grpc_token,
595 &request_abs,
596 &request_file,
597 )?;
598
599 status = grpc_status;
600 message = grpc_message;
601 assertions = grpc_assertions;
602 command_snippet = grpc_command_snippet;
603 stdout_file_abs = Some(stdout_path);
604 stderr_file_abs = Some(stderr_path);
605
606 match status.as_str() {
607 "passed" => passed += 1,
608 "failed" => failed += 1,
609 _ => {}
610 }
611 }
612 },
613 other => {
614 anyhow::bail!("Unknown case type '{other}' for case '{id}'");
615 }
616 }
617
618 let duration_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
619
620 if status == "passed" || status == "failed" {
622 let cleanup_ok = if let (Some(stderr_path), Some(cleanup)) =
623 (stderr_file_abs.as_deref(), c.cleanup.as_ref())
624 {
625 let mut ctx = CleanupContext {
626 repo_root,
627 run_dir: &run_dir_abs,
628 case_id: &id,
629 safe_id: &safe_id,
630 main_response_file: stdout_file_abs.as_deref(),
631 main_stderr_file: stderr_path,
632 allow_writes_flag: options.allow_writes_flag,
633 effective_env: &effective_env,
634 effective_no_history,
635 suite_defaults: defaults,
636 env_rest_url: &options.env_rest_url,
637 env_gql_url: &options.env_gql_url,
638 rest_config_dir: &rest_config_dir,
639 rest_url: &rest_url,
640 rest_token: &rest_token,
641 gql_config_dir: &gql_config_dir,
642 gql_url: &gql_url,
643 gql_jwt: &gql_jwt,
644 access_token_for_case: &access_token_for_case,
645 auth_manager: auth_manager.as_mut(),
646 cleanup: Some(cleanup),
647 };
648 run_case_cleanup(&mut ctx)?
649 } else {
650 true
651 };
652
653 if !cleanup_ok && status == "passed" {
654 status = "failed".to_string();
655 message = Some("cleanup_failed".to_string());
656 passed = passed.saturating_sub(1);
657 failed += 1;
658 }
659 }
660
661 cases_out.push(SuiteCaseResult {
662 id,
663 case_type: ty,
664 status: status.clone(),
665 duration_ms,
666 tags,
667 command: command_snippet,
668 message,
669 assertions,
670 stdout_file: stdout_file_abs
671 .as_deref()
672 .map(|p| path_relative_to_repo_or_abs(repo_root, p)),
673 stderr_file: stderr_file_abs
674 .as_deref()
675 .map(|p| path_relative_to_repo_or_abs(repo_root, p)),
676 });
677
678 if options.fail_fast && status == "failed" {
679 break;
680 }
681 }
682
683 let finished_at = time_iso_now()?;
684
685 let results = SuiteRunResults {
686 version: 1,
687 suite: suite_name_value,
688 suite_file: suite_file_rel,
689 run_id,
690 started_at,
691 finished_at,
692 output_dir: output_dir_rel,
693 summary: SuiteRunSummary {
694 total,
695 passed,
696 failed,
697 skipped,
698 },
699 cases: cases_out,
700 };
701
702 if let Some(msg) = auth_init_message {
703 let path = run_dir_abs.join("auth.disabled.log");
705 let _ = write_file(&path, format!("{msg}\n").as_bytes());
706 }
707
708 Ok(SuiteRunOutput {
709 run_dir_abs,
710 results,
711 })
712}
713
714#[allow(clippy::too_many_arguments)]
715fn case_result(
716 id: &str,
717 case_type: &str,
718 tags: &[String],
719 status: &str,
720 duration: std::time::Duration,
721 command: Option<String>,
722 message: Option<String>,
723 assertions: Option<serde_json::Value>,
724 stdout_file: Option<&Path>,
725 stderr_file: Option<&Path>,
726 repo_root: &Path,
727) -> SuiteCaseResult {
728 SuiteCaseResult {
729 id: id.to_string(),
730 case_type: case_type.to_string(),
731 status: status.to_string(),
732 duration_ms: duration.as_millis().try_into().unwrap_or(u64::MAX),
733 tags: tags.to_vec(),
734 command,
735 message,
736 assertions,
737 stdout_file: stdout_file.map(|p| path_relative_to_repo_or_abs(repo_root, p)),
738 stderr_file: stderr_file.map(|p| path_relative_to_repo_or_abs(repo_root, p)),
739 }
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745 use crate::suite::runtime;
746 use crate::suite::safety::MSG_WRITE_CASES_DISABLED;
747 use crate::suite::schema::{SuiteCase, SuiteDefaults, SuiteManifest};
748 use nils_term::progress::Progress;
749 use nils_term::progress::{ProgressDrawTarget, ProgressEnabled, ProgressOptions};
750 use nils_test_support::fixtures::{GraphqlSetupFixture, RestSetupFixture};
751 use pretty_assertions::assert_eq;
752 use std::collections::HashSet;
753 use std::io::{Read, Write};
754 use std::net::{TcpListener, TcpStream};
755 use std::sync::mpsc;
756 use std::sync::{Arc, Mutex};
757 use std::thread;
758 use std::time::Duration;
759
760 use tempfile::TempDir;
761
762 fn base_case(id: &str, case_type: &str) -> SuiteCase {
763 SuiteCase {
764 id: id.to_string(),
765 case_type: case_type.to_string(),
766 tags: Vec::new(),
767 env: String::new(),
768 no_history: None,
769 allow_write: false,
770 config_dir: String::new(),
771 url: String::new(),
772 token: String::new(),
773 request: String::new(),
774 grpc_proto: None,
775 grpc_import_paths: None,
776 grpc_plaintext: None,
777 login_request: String::new(),
778 token_jq: String::new(),
779 jwt: String::new(),
780 op: String::new(),
781 vars: None,
782 allow_errors: false,
783 expect: None,
784 cleanup: None,
785 }
786 }
787
788 struct TestServer {
789 base_url: String,
790 shutdown: mpsc::Sender<()>,
791 join: Option<thread::JoinHandle<()>>,
792 }
793
794 impl Drop for TestServer {
795 fn drop(&mut self) {
796 let _ = self.shutdown.send(());
797 if let Some(j) = self.join.take() {
798 let _ = j.join();
799 }
800 }
801 }
802
803 fn read_until_headers_end(stream: &mut TcpStream) {
804 let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
805 let mut buf = Vec::new();
806 let mut tmp = [0u8; 2048];
807 loop {
808 match stream.read(&mut tmp) {
809 Ok(0) => break,
810 Ok(n) => {
811 buf.extend_from_slice(&tmp[..n]);
812 if buf.windows(4).any(|w| w == b"\r\n\r\n") {
813 break;
814 }
815 if buf.len() > 64 * 1024 {
816 break;
817 }
818 }
819 Err(_) => break,
820 }
821 }
822 }
823
824 fn write_json_response(stream: &mut TcpStream, body: &[u8]) {
825 let mut resp = Vec::new();
826 resp.extend_from_slice(b"HTTP/1.1 200 OK\r\n");
827 resp.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
828 resp.extend_from_slice(b"Content-Type: application/json\r\n");
829 resp.extend_from_slice(b"\r\n");
830 resp.extend_from_slice(body);
831 let _ = stream.write_all(&resp);
832 let _ = stream.flush();
833 }
834
835 fn start_server() -> TestServer {
836 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
837 listener.set_nonblocking(true).expect("nonblocking");
838 let addr = listener.local_addr().expect("addr");
839 let base_url = format!("http://{addr}");
840
841 let (tx, rx) = mpsc::channel::<()>();
842 let join = thread::spawn(move || {
843 loop {
844 if rx.try_recv().is_ok() {
845 break;
846 }
847 match listener.accept() {
848 Ok((mut stream, _peer)) => {
849 read_until_headers_end(&mut stream);
850 write_json_response(&mut stream, br#"{"ok":true}"#);
851 }
852 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
853 thread::sleep(Duration::from_millis(5));
854 }
855 Err(_) => break,
856 }
857 }
858 });
859
860 TestServer {
861 base_url,
862 shutdown: tx,
863 join: Some(join),
864 }
865 }
866
867 fn read_output(buffer: &Arc<Mutex<Vec<u8>>>) -> String {
868 String::from_utf8_lossy(&buffer.lock().expect("buffer lock")).to_string()
869 }
870
871 fn normalize_progress_output(s: &str) -> String {
872 let mut out = String::with_capacity(s.len());
873 let bytes = s.as_bytes();
874 let mut i = 0;
875 while i < bytes.len() {
876 if bytes[i] == b'\r' {
877 i += 1;
878 continue;
879 }
880
881 if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
882 i += 2;
883 while i < bytes.len() {
884 let b = bytes[i];
885 i += 1;
886 if b.is_ascii_alphabetic() {
887 break;
888 }
889 }
890 continue;
891 }
892
893 out.push(bytes[i] as char);
894 i += 1;
895 }
896 out
897 }
898
899 #[test]
900 fn suite_runner_sanitize_id_matches_expected_replacements() {
901 assert_eq!(runtime::sanitize_id("rest.health"), "rest.health");
902 assert_eq!(runtime::sanitize_id("a b c"), "a-b-c");
903 assert_eq!(runtime::sanitize_id(""), "case");
904 }
905
906 #[test]
907 fn suite_runner_sanitize_id_handles_punctuation_and_unicode() {
908 assert_eq!(runtime::sanitize_id("foo,bar"), "foo-bar");
909 assert_eq!(runtime::sanitize_id("foo✅bar"), "foo-bar");
910 assert_eq!(runtime::sanitize_id("✅foo"), "foo");
911 assert_eq!(runtime::sanitize_id("foo!!!"), "foo");
912 assert_eq!(runtime::sanitize_id("!!!"), "case");
913 }
914
915 #[test]
916 fn suite_runner_masks_token_args_in_command_snippet() {
917 let args = vec![
918 "--token".to_string(),
919 "secret".to_string(),
920 "--jwt=jwt-value".to_string(),
921 "--other".to_string(),
922 "keep".to_string(),
923 "--token=inline".to_string(),
924 "--jwt".to_string(),
925 "another".to_string(),
926 ];
927
928 let masked = mask_args_for_command_snippet(&args);
929
930 assert_eq!(
931 masked,
932 "'--token' 'REDACTED' '--jwt=REDACTED' '--other' 'keep' '--token=REDACTED' '--jwt' 'REDACTED'"
933 );
934 }
935
936 #[test]
937 fn suite_runner_resolve_rest_base_url_precedence() {
938 let fixture = RestSetupFixture::new();
939 fixture.write_endpoints_env("REST_URL_STAGE=http://env-file\n");
940
941 let defaults = SuiteDefaults::default();
942 assert_eq!(
943 runtime::resolve_rest_base_url(&fixture.root, "setup/rest", "", "", &defaults, "")
944 .unwrap(),
945 "http://localhost:6700"
946 );
947 assert_eq!(
948 runtime::resolve_rest_base_url(&fixture.root, "setup/rest", "", "stage", &defaults, "")
949 .unwrap(),
950 "http://env-file"
951 );
952 assert_eq!(
953 runtime::resolve_rest_base_url(
954 &fixture.root,
955 "setup/rest",
956 "",
957 "stage",
958 &defaults,
959 "http://env-var"
960 )
961 .unwrap(),
962 "http://env-var"
963 );
964
965 let mut defaults_with_url = SuiteDefaults::default();
966 defaults_with_url.rest.url = "http://default".to_string();
967 assert_eq!(
968 runtime::resolve_rest_base_url(
969 &fixture.root,
970 "setup/rest",
971 "",
972 "stage",
973 &defaults_with_url,
974 "http://env-var"
975 )
976 .unwrap(),
977 "http://default"
978 );
979 assert_eq!(
980 runtime::resolve_rest_base_url(
981 &fixture.root,
982 "setup/rest",
983 "http://override",
984 "stage",
985 &defaults_with_url,
986 "http://env-var"
987 )
988 .unwrap(),
989 "http://override"
990 );
991 }
992
993 #[test]
994 fn suite_runner_resolve_gql_url_precedence() {
995 let fixture = GraphqlSetupFixture::new();
996 fixture.write_endpoints_env("GQL_URL_STAGE=http://env-file/graphql\n");
997
998 let defaults = SuiteDefaults::default();
999 assert_eq!(
1000 runtime::resolve_gql_url(&fixture.root, "setup/graphql", "", "", &defaults, "")
1001 .unwrap(),
1002 "http://localhost:6700/graphql"
1003 );
1004 assert_eq!(
1005 runtime::resolve_gql_url(&fixture.root, "setup/graphql", "", "stage", &defaults, "")
1006 .unwrap(),
1007 "http://env-file/graphql"
1008 );
1009 assert_eq!(
1010 runtime::resolve_gql_url(
1011 &fixture.root,
1012 "setup/graphql",
1013 "",
1014 "stage",
1015 &defaults,
1016 "http://env-var/graphql"
1017 )
1018 .unwrap(),
1019 "http://env-var/graphql"
1020 );
1021
1022 let mut defaults_with_url = SuiteDefaults::default();
1023 defaults_with_url.graphql.url = "http://default/graphql".to_string();
1024 assert_eq!(
1025 runtime::resolve_gql_url(
1026 &fixture.root,
1027 "setup/graphql",
1028 "",
1029 "stage",
1030 &defaults_with_url,
1031 "http://env-var/graphql"
1032 )
1033 .unwrap(),
1034 "http://default/graphql"
1035 );
1036 assert_eq!(
1037 runtime::resolve_gql_url(
1038 &fixture.root,
1039 "setup/graphql",
1040 "http://override/graphql",
1041 "stage",
1042 &defaults_with_url,
1043 "http://env-var/graphql"
1044 )
1045 .unwrap(),
1046 "http://override/graphql"
1047 );
1048 }
1049
1050 #[test]
1051 fn suite_runner_resolve_rest_token_profile_from_tokens_files() {
1052 let fixture = RestSetupFixture::new();
1053 fixture.write_tokens_env("REST_TOKEN_TEAM_ALPHA=base\n");
1054 fixture.write_tokens_local_env("REST_TOKEN_TEAM_ALPHA=local\n");
1055
1056 let token = runtime::resolve_rest_token_profile(&fixture.setup_dir, "team alpha").unwrap();
1057 assert_eq!(token, "local");
1058 }
1059
1060 #[test]
1061 fn suite_runner_token_profile_ignores_leading_separators() {
1062 let fixture = RestSetupFixture::new();
1063 fixture.write_tokens_env("REST_TOKEN_TEAM_ALPHA=base\n");
1064
1065 let token = runtime::resolve_rest_token_profile(&fixture.setup_dir, "-team alpha").unwrap();
1066 assert_eq!(token, "base");
1067 }
1068
1069 #[test]
1070 fn suite_runner_empty_suite_has_zero_counts() {
1071 let tmp = TempDir::new().unwrap();
1072 let suite_path = tmp.path().join("suite.json");
1073 std::fs::write(&suite_path, br#"{"version":1,"cases":[]}"#).unwrap();
1074
1075 let manifest = SuiteManifest {
1076 version: 1,
1077 name: String::new(),
1078 defaults: SuiteDefaults::default(),
1079 auth: None,
1080 cases: Vec::new(),
1081 };
1082 let loaded = LoadedSuite {
1083 suite_path: suite_path.clone(),
1084 manifest,
1085 };
1086
1087 let options = SuiteRunOptions {
1088 required_tags: Vec::new(),
1089 only_ids: HashSet::new(),
1090 skip_ids: HashSet::new(),
1091 allow_writes_flag: true,
1092 fail_fast: false,
1093 output_dir_base: tmp.path().join("out"),
1094 env_rest_url: String::new(),
1095 env_gql_url: String::new(),
1096 env_grpc_url: String::new(),
1097 progress: None,
1098 };
1099
1100 let out = run_suite(tmp.path(), loaded, options).unwrap();
1101 assert_eq!(out.results.summary.total, 0);
1102 assert_eq!(out.results.summary.passed, 0);
1103 assert_eq!(out.results.summary.failed, 0);
1104 assert_eq!(out.results.summary.skipped, 0);
1105 assert!(out.run_dir_abs.is_dir());
1106 }
1107
1108 #[test]
1109 fn suite_runner_skips_case_when_tag_mismatch() {
1110 let tmp = TempDir::new().unwrap();
1111 let suite_path = tmp.path().join("suite.json");
1112 std::fs::write(&suite_path, br#"{"version":1,"cases":[]}"#).unwrap();
1113
1114 let mut case = base_case("case-1", "rest");
1115 case.tags = vec!["smoke".to_string()];
1116
1117 let manifest = SuiteManifest {
1118 version: 1,
1119 name: String::new(),
1120 defaults: SuiteDefaults::default(),
1121 auth: None,
1122 cases: vec![case],
1123 };
1124 let loaded = LoadedSuite {
1125 suite_path: suite_path.clone(),
1126 manifest,
1127 };
1128
1129 let options = SuiteRunOptions {
1130 required_tags: vec!["fast".to_string()],
1131 only_ids: HashSet::new(),
1132 skip_ids: HashSet::new(),
1133 allow_writes_flag: true,
1134 fail_fast: false,
1135 output_dir_base: tmp.path().join("out"),
1136 env_rest_url: String::new(),
1137 env_gql_url: String::new(),
1138 env_grpc_url: String::new(),
1139 progress: None,
1140 };
1141
1142 let out = run_suite(tmp.path(), loaded, options).unwrap();
1143 assert_eq!(out.results.summary.total, 1);
1144 assert_eq!(out.results.summary.skipped, 1);
1145 assert_eq!(out.results.cases[0].status, "skipped");
1146 assert_eq!(
1147 out.results.cases[0].message.as_deref(),
1148 Some("tag_mismatch")
1149 );
1150 }
1151
1152 #[test]
1153 fn suite_runner_skips_write_case_when_writes_disabled() {
1154 let tmp = TempDir::new().unwrap();
1155 let suite_path = tmp.path().join("suite.json");
1156 std::fs::write(&suite_path, br#"{"version":1,"cases":[]}"#).unwrap();
1157
1158 let request_path = tmp.path().join("requests/post.request.json");
1159 std::fs::create_dir_all(request_path.parent().unwrap()).unwrap();
1160 std::fs::write(
1161 &request_path,
1162 br#"{"method":"POST","path":"/do","expect":{"status":200}}"#,
1163 )
1164 .unwrap();
1165
1166 let mut case = base_case("write-case", "rest");
1167 case.allow_write = true;
1168 case.request = "requests/post.request.json".to_string();
1169
1170 let manifest = SuiteManifest {
1171 version: 1,
1172 name: String::new(),
1173 defaults: SuiteDefaults::default(),
1174 auth: None,
1175 cases: vec![case],
1176 };
1177 let loaded = LoadedSuite {
1178 suite_path: suite_path.clone(),
1179 manifest,
1180 };
1181
1182 let options = SuiteRunOptions {
1183 required_tags: Vec::new(),
1184 only_ids: HashSet::new(),
1185 skip_ids: HashSet::new(),
1186 allow_writes_flag: false,
1187 fail_fast: false,
1188 output_dir_base: tmp.path().join("out"),
1189 env_rest_url: String::new(),
1190 env_gql_url: String::new(),
1191 env_grpc_url: String::new(),
1192 progress: None,
1193 };
1194
1195 let out = run_suite(tmp.path(), loaded, options).unwrap();
1196 assert_eq!(out.results.summary.total, 1);
1197 assert_eq!(out.results.summary.skipped, 1);
1198 assert_eq!(out.results.cases[0].status, "skipped");
1199 assert_eq!(
1200 out.results.cases[0].message.as_deref(),
1201 Some(MSG_WRITE_CASES_DISABLED)
1202 );
1203 }
1204
1205 #[test]
1206 fn suite_runner_unknown_case_type_is_error() {
1207 let tmp = TempDir::new().unwrap();
1208 let suite_path = tmp.path().join("suite.json");
1209 std::fs::write(&suite_path, br#"{"version":1,"cases":[]}"#).unwrap();
1210
1211 let case = base_case("case-1", "weird");
1212 let manifest = SuiteManifest {
1213 version: 1,
1214 name: String::new(),
1215 defaults: SuiteDefaults::default(),
1216 auth: None,
1217 cases: vec![case],
1218 };
1219 let loaded = LoadedSuite {
1220 suite_path: suite_path.clone(),
1221 manifest,
1222 };
1223
1224 let options = SuiteRunOptions {
1225 required_tags: Vec::new(),
1226 only_ids: HashSet::new(),
1227 skip_ids: HashSet::new(),
1228 allow_writes_flag: true,
1229 fail_fast: false,
1230 output_dir_base: tmp.path().join("out"),
1231 env_rest_url: String::new(),
1232 env_gql_url: String::new(),
1233 env_grpc_url: String::new(),
1234 progress: None,
1235 };
1236
1237 let err = run_suite(tmp.path(), loaded, options).unwrap_err();
1238 assert!(format!("{err:#}").contains("Unknown case type 'weird'"));
1239 }
1240
1241 #[test]
1242 fn suite_runner_no_history_flag_in_command_snippet() {
1243 let tmp = TempDir::new().unwrap();
1244 let suite_path = tmp.path().join("suite.json");
1245 std::fs::write(&suite_path, br#"{"version":1,"cases":[]}"#).unwrap();
1246
1247 let request_path = tmp.path().join("requests/health.request.json");
1248 std::fs::create_dir_all(request_path.parent().unwrap()).unwrap();
1249 std::fs::write(
1250 &request_path,
1251 br#"{"method":"GET","path":"/health","expect":{"status":200}}"#,
1252 )
1253 .unwrap();
1254
1255 let server = start_server();
1256
1257 let defaults = SuiteDefaults {
1258 no_history: true,
1259 ..Default::default()
1260 };
1261
1262 let mut case = base_case("case-1", "rest");
1263 case.request = "requests/health.request.json".to_string();
1264
1265 let manifest = SuiteManifest {
1266 version: 1,
1267 name: String::new(),
1268 defaults,
1269 auth: None,
1270 cases: vec![case],
1271 };
1272 let loaded = LoadedSuite {
1273 suite_path: suite_path.clone(),
1274 manifest,
1275 };
1276
1277 let options = SuiteRunOptions {
1278 required_tags: Vec::new(),
1279 only_ids: HashSet::new(),
1280 skip_ids: HashSet::new(),
1281 allow_writes_flag: true,
1282 fail_fast: false,
1283 output_dir_base: tmp.path().join("out"),
1284 env_rest_url: server.base_url.clone(),
1285 env_gql_url: String::new(),
1286 env_grpc_url: String::new(),
1287 progress: None,
1288 };
1289
1290 let out = run_suite(tmp.path(), loaded, options).unwrap();
1291 let cmd = out.results.cases[0].command.as_deref().unwrap_or("");
1292 assert!(cmd.contains("--no-history"));
1293 assert!(cmd.contains("--url"));
1294 }
1295
1296 #[test]
1297 fn suite_runner_progress_disabled_is_noop() {
1298 let tmp = TempDir::new().unwrap();
1299 let suite_path = tmp.path().join("suite.json");
1300 std::fs::write(&suite_path, br#"{"version":1,"cases":[]}"#).unwrap();
1301
1302 let mut case = base_case("case-1", "rest");
1303 case.tags = vec!["smoke".to_string()];
1304
1305 let manifest = SuiteManifest {
1306 version: 1,
1307 name: String::new(),
1308 defaults: SuiteDefaults::default(),
1309 auth: None,
1310 cases: vec![case],
1311 };
1312 let loaded = LoadedSuite {
1313 suite_path: suite_path.clone(),
1314 manifest,
1315 };
1316
1317 let buffer = Arc::new(Mutex::new(Vec::new()));
1318 let progress = Progress::new(
1319 1,
1320 ProgressOptions::default()
1321 .with_enabled(ProgressEnabled::Off)
1322 .with_draw_target(ProgressDrawTarget::to_writer(buffer.clone()))
1323 .with_width(Some(60)),
1324 );
1325
1326 let options = SuiteRunOptions {
1327 required_tags: vec!["fast".to_string()],
1328 only_ids: HashSet::new(),
1329 skip_ids: HashSet::new(),
1330 allow_writes_flag: true,
1331 fail_fast: false,
1332 output_dir_base: tmp.path().join("out"),
1333 env_rest_url: String::new(),
1334 env_gql_url: String::new(),
1335 env_grpc_url: String::new(),
1336 progress: Some(progress),
1337 };
1338
1339 let out = run_suite(tmp.path(), loaded, options).unwrap();
1340 assert_eq!(out.results.summary.total, 1);
1341 assert_eq!(out.results.summary.skipped, 1);
1342 assert!(read_output(&buffer).is_empty());
1343 }
1344
1345 #[test]
1346 fn suite_runner_progress_updates_position_and_message() {
1347 let tmp = TempDir::new().unwrap();
1348 let suite_path = tmp.path().join("suite.json");
1349 std::fs::write(&suite_path, br#"{"version":1,"cases":[]}"#).unwrap();
1350
1351 let mut case = base_case("case-1", "rest");
1352 case.tags = vec!["smoke".to_string()];
1353
1354 let manifest = SuiteManifest {
1355 version: 1,
1356 name: String::new(),
1357 defaults: SuiteDefaults::default(),
1358 auth: None,
1359 cases: vec![case],
1360 };
1361 let loaded = LoadedSuite {
1362 suite_path: suite_path.clone(),
1363 manifest,
1364 };
1365
1366 let buffer = Arc::new(Mutex::new(Vec::new()));
1367 let progress = Progress::new(
1368 1,
1369 ProgressOptions::default()
1370 .with_enabled(ProgressEnabled::Auto)
1371 .with_draw_target(ProgressDrawTarget::to_writer(buffer.clone()))
1372 .with_width(Some(60)),
1373 );
1374
1375 let options = SuiteRunOptions {
1376 required_tags: vec!["fast".to_string()],
1377 only_ids: HashSet::new(),
1378 skip_ids: HashSet::new(),
1379 allow_writes_flag: true,
1380 fail_fast: false,
1381 output_dir_base: tmp.path().join("out"),
1382 env_rest_url: String::new(),
1383 env_gql_url: String::new(),
1384 env_grpc_url: String::new(),
1385 progress: Some(progress),
1386 };
1387
1388 let out = run_suite(tmp.path(), loaded, options).unwrap();
1389 assert_eq!(out.results.summary.total, 1);
1390 assert_eq!(out.results.summary.skipped, 1);
1391
1392 let normalized = normalize_progress_output(&read_output(&buffer));
1393 assert!(normalized.contains("1/1"), "output was: {normalized:?}");
1394 assert!(normalized.contains("case-1"), "output was: {normalized:?}");
1395 }
1396}