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