Skip to main content

api_testing_core/suite/runner/
mod.rs

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                        // Command snippet (parity intent; uses jq extraction).
282                        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                        // Login request
326                        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                        // Main request with extracted token
420                        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        // Cleanup hook (best-effort; can flip passed->failed).
621        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        // Best-effort: write to runner stderr file in output dir, to avoid losing context.
704        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}