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