Skip to main content

api_testing_core/
config.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4
5use crate::Result;
6
7const SETUP_DIR_ERROR: &str = "Failed to resolve setup dir (try --config-dir).";
8const INVOCATION_DIR_ERROR: &str = "Failed to resolve invocation dir for setup discovery";
9const SETUP_GRAPHQL_ERROR: &str = "failed to resolve setup/graphql";
10const SETUP_GRPC_ERROR: &str = "failed to resolve setup/grpc";
11const SETUP_WEBSOCKET_ERROR: &str = "failed to resolve setup/websocket";
12
13#[derive(Debug, Clone, Copy)]
14enum FallbackMode {
15    None,
16    Upwards(&'static str),
17    Direct(&'static str, &'static str),
18}
19
20#[derive(Debug)]
21struct SetupDiscovery<'a> {
22    cwd: &'a Path,
23    seed: PathBuf,
24    config_dir_explicit: bool,
25    files: &'static [&'static str],
26    seed_fallback: FallbackMode,
27    invocation_dir: Option<&'a Path>,
28    invocation_fallback: FallbackMode,
29}
30
31impl<'a> SetupDiscovery<'a> {
32    fn resolve(&self) -> Result<PathBuf> {
33        let seed_abs = abs_dir(self.cwd, &self.seed).context(SETUP_DIR_ERROR)?;
34
35        if let Some(dir) = find_upwards_for_files(&seed_abs, self.files) {
36            return Ok(dir);
37        }
38
39        if let FallbackMode::Upwards(subdir) = self.seed_fallback
40            && let Some(found_setup) = find_upwards_for_setup_subdir(&seed_abs, subdir)
41        {
42            return Ok(found_setup);
43        }
44
45        if self.config_dir_explicit {
46            return Ok(seed_abs);
47        }
48
49        match self.invocation_fallback {
50            FallbackMode::None => {}
51            FallbackMode::Upwards(subdir) => {
52                let invocation_dir = self
53                    .invocation_dir
54                    .expect("invocation_dir required for upwards fallback");
55                let invocation_abs =
56                    abs_dir(self.cwd, invocation_dir).context(INVOCATION_DIR_ERROR)?;
57                if let Some(found_setup) = find_upwards_for_setup_subdir(&invocation_abs, subdir) {
58                    return Ok(found_setup);
59                }
60            }
61            FallbackMode::Direct(subdir, ctx) => {
62                let invocation_dir = self
63                    .invocation_dir
64                    .expect("invocation_dir required for direct fallback");
65                let invocation_abs =
66                    abs_dir(self.cwd, invocation_dir).context(INVOCATION_DIR_ERROR)?;
67                let fallback = invocation_abs.join(subdir);
68                if fallback.is_dir() {
69                    return abs_dir(self.cwd, &fallback).context(ctx);
70                }
71            }
72        }
73
74        Ok(seed_abs)
75    }
76}
77
78fn abs_dir(base_dir: &Path, path: &Path) -> Result<PathBuf> {
79    let joined = if path.is_absolute() {
80        path.to_path_buf()
81    } else {
82        base_dir.join(path)
83    };
84
85    std::fs::canonicalize(&joined)
86        .with_context(|| format!("failed to resolve directory path: {}", joined.display()))
87}
88
89fn find_upwards_for_file(start_dir: &Path, filename: &str) -> Option<PathBuf> {
90    let mut dir = start_dir;
91    loop {
92        if dir.join(filename).is_file() {
93            return Some(dir.to_path_buf());
94        }
95
96        match dir.parent() {
97            Some(parent) if parent != dir => dir = parent,
98            _ => return None,
99        }
100    }
101}
102
103fn find_upwards_for_files(start_dir: &Path, filenames: &[&str]) -> Option<PathBuf> {
104    for filename in filenames {
105        if let Some(found) = find_upwards_for_file(start_dir, filename) {
106            return Some(found);
107        }
108    }
109    None
110}
111
112fn find_upwards_for_setup_subdir(start_dir: &Path, rel_subdir: &str) -> Option<PathBuf> {
113    let mut dir = start_dir;
114    loop {
115        let candidate = dir.join(rel_subdir);
116        if candidate.is_dir() {
117            return Some(candidate);
118        }
119
120        match dir.parent() {
121            Some(parent) if parent != dir => dir = parent,
122            _ => return None,
123        }
124    }
125}
126
127pub fn resolve_rest_setup_dir_for_call(
128    cwd: &Path,
129    invocation_dir: &Path,
130    request_file: &Path,
131    config_dir: Option<&Path>,
132) -> Result<PathBuf> {
133    let seed = match config_dir {
134        Some(dir) => dir.to_path_buf(),
135        None => request_file
136            .parent()
137            .map(PathBuf::from)
138            .unwrap_or_else(|| PathBuf::from(".")),
139    };
140
141    SetupDiscovery {
142        cwd,
143        seed,
144        config_dir_explicit: config_dir.is_some(),
145        files: &[
146            "endpoints.env",
147            "tokens.env",
148            "endpoints.local.env",
149            "tokens.local.env",
150        ],
151        seed_fallback: FallbackMode::Upwards("setup/rest"),
152        invocation_dir: Some(invocation_dir),
153        invocation_fallback: FallbackMode::Upwards("setup/rest"),
154    }
155    .resolve()
156}
157
158pub fn resolve_rest_setup_dir_for_history(
159    cwd: &Path,
160    config_dir: Option<&Path>,
161) -> Result<PathBuf> {
162    let seed = config_dir.unwrap_or_else(|| Path::new("."));
163
164    SetupDiscovery {
165        cwd,
166        seed: seed.to_path_buf(),
167        config_dir_explicit: config_dir.is_some(),
168        files: &[
169            ".rest_history",
170            "endpoints.env",
171            "tokens.env",
172            "tokens.local.env",
173        ],
174        seed_fallback: FallbackMode::Upwards("setup/rest"),
175        invocation_dir: None,
176        invocation_fallback: FallbackMode::None,
177    }
178    .resolve()
179}
180
181pub fn resolve_gql_setup_dir_for_call(
182    cwd: &Path,
183    invocation_dir: &Path,
184    operation_file: Option<&Path>,
185    config_dir: Option<&Path>,
186) -> Result<PathBuf> {
187    let seed = match config_dir {
188        Some(dir) => dir.to_path_buf(),
189        None => operation_file
190            .and_then(|p| p.parent())
191            .map(PathBuf::from)
192            .unwrap_or_else(|| PathBuf::from(".")),
193    };
194
195    SetupDiscovery {
196        cwd,
197        seed,
198        config_dir_explicit: config_dir.is_some(),
199        files: &["endpoints.env", "jwts.env", "jwts.local.env"],
200        seed_fallback: FallbackMode::None,
201        invocation_dir: Some(invocation_dir),
202        invocation_fallback: FallbackMode::Direct("setup/graphql", SETUP_GRAPHQL_ERROR),
203    }
204    .resolve()
205}
206
207pub fn resolve_gql_setup_dir_for_history(
208    cwd: &Path,
209    invocation_dir: &Path,
210    config_dir: Option<&Path>,
211) -> Result<PathBuf> {
212    let seed = config_dir.unwrap_or_else(|| Path::new("."));
213
214    SetupDiscovery {
215        cwd,
216        seed: seed.to_path_buf(),
217        config_dir_explicit: config_dir.is_some(),
218        files: &[
219            ".gql_history",
220            "endpoints.env",
221            "jwts.env",
222            "jwts.local.env",
223        ],
224        seed_fallback: FallbackMode::None,
225        invocation_dir: Some(invocation_dir),
226        invocation_fallback: FallbackMode::Direct("setup/graphql", SETUP_GRAPHQL_ERROR),
227    }
228    .resolve()
229}
230
231pub fn resolve_gql_setup_dir_for_schema(
232    cwd: &Path,
233    invocation_dir: &Path,
234    config_dir: Option<&Path>,
235) -> Result<PathBuf> {
236    let seed = config_dir.unwrap_or_else(|| Path::new("."));
237
238    SetupDiscovery {
239        cwd,
240        seed: seed.to_path_buf(),
241        config_dir_explicit: config_dir.is_some(),
242        files: &[
243            "schema.env",
244            "schema.local.env",
245            "endpoints.env",
246            "jwts.env",
247            "jwts.local.env",
248        ],
249        seed_fallback: FallbackMode::None,
250        invocation_dir: Some(invocation_dir),
251        invocation_fallback: FallbackMode::Direct("setup/graphql", SETUP_GRAPHQL_ERROR),
252    }
253    .resolve()
254}
255
256pub fn resolve_grpc_setup_dir_for_call(
257    cwd: &Path,
258    invocation_dir: &Path,
259    request_file: &Path,
260    config_dir: Option<&Path>,
261) -> Result<PathBuf> {
262    let seed = match config_dir {
263        Some(dir) => dir.to_path_buf(),
264        None => request_file
265            .parent()
266            .map(PathBuf::from)
267            .unwrap_or_else(|| PathBuf::from(".")),
268    };
269
270    SetupDiscovery {
271        cwd,
272        seed,
273        config_dir_explicit: config_dir.is_some(),
274        files: &[
275            "endpoints.env",
276            "tokens.env",
277            "endpoints.local.env",
278            "tokens.local.env",
279        ],
280        seed_fallback: FallbackMode::None,
281        invocation_dir: Some(invocation_dir),
282        invocation_fallback: FallbackMode::Direct("setup/grpc", SETUP_GRPC_ERROR),
283    }
284    .resolve()
285}
286
287pub fn resolve_grpc_setup_dir_for_history(
288    cwd: &Path,
289    invocation_dir: &Path,
290    config_dir: Option<&Path>,
291) -> Result<PathBuf> {
292    let seed = config_dir.unwrap_or_else(|| Path::new("."));
293
294    SetupDiscovery {
295        cwd,
296        seed: seed.to_path_buf(),
297        config_dir_explicit: config_dir.is_some(),
298        files: &[
299            ".grpc_history",
300            "endpoints.env",
301            "tokens.env",
302            "endpoints.local.env",
303            "tokens.local.env",
304        ],
305        seed_fallback: FallbackMode::None,
306        invocation_dir: Some(invocation_dir),
307        invocation_fallback: FallbackMode::Direct("setup/grpc", SETUP_GRPC_ERROR),
308    }
309    .resolve()
310}
311
312pub fn resolve_websocket_setup_dir_for_call(
313    cwd: &Path,
314    invocation_dir: &Path,
315    request_file: &Path,
316    config_dir: Option<&Path>,
317) -> Result<PathBuf> {
318    let seed = match config_dir {
319        Some(dir) => dir.to_path_buf(),
320        None => request_file
321            .parent()
322            .map(PathBuf::from)
323            .unwrap_or_else(|| PathBuf::from(".")),
324    };
325
326    SetupDiscovery {
327        cwd,
328        seed,
329        config_dir_explicit: config_dir.is_some(),
330        files: &[
331            "endpoints.env",
332            "tokens.env",
333            "endpoints.local.env",
334            "tokens.local.env",
335        ],
336        seed_fallback: FallbackMode::None,
337        invocation_dir: Some(invocation_dir),
338        invocation_fallback: FallbackMode::Direct("setup/websocket", SETUP_WEBSOCKET_ERROR),
339    }
340    .resolve()
341}
342
343pub fn resolve_websocket_setup_dir_for_history(
344    cwd: &Path,
345    invocation_dir: &Path,
346    config_dir: Option<&Path>,
347) -> Result<PathBuf> {
348    let seed = config_dir.unwrap_or_else(|| Path::new("."));
349
350    SetupDiscovery {
351        cwd,
352        seed: seed.to_path_buf(),
353        config_dir_explicit: config_dir.is_some(),
354        files: &[
355            ".ws_history",
356            "endpoints.env",
357            "tokens.env",
358            "endpoints.local.env",
359            "tokens.local.env",
360        ],
361        seed_fallback: FallbackMode::None,
362        invocation_dir: Some(invocation_dir),
363        invocation_fallback: FallbackMode::Direct("setup/websocket", SETUP_WEBSOCKET_ERROR),
364    }
365    .resolve()
366}
367
368#[derive(Debug, Clone)]
369pub struct ResolvedSetup {
370    pub setup_dir: PathBuf,
371    pub history_file: PathBuf,
372    pub endpoints_env: PathBuf,
373    pub endpoints_local_env: PathBuf,
374    pub tokens_env: Option<PathBuf>,
375    pub tokens_local_env: Option<PathBuf>,
376    pub jwts_env: Option<PathBuf>,
377    pub jwts_local_env: Option<PathBuf>,
378}
379
380impl ResolvedSetup {
381    pub fn rest(setup_dir: PathBuf, history_override: Option<&Path>) -> Self {
382        let history_file =
383            crate::history::resolve_history_file(&setup_dir, history_override, ".rest_history");
384        let endpoints_env = setup_dir.join("endpoints.env");
385        let endpoints_local_env = setup_dir.join("endpoints.local.env");
386        let tokens_env = setup_dir.join("tokens.env");
387        let tokens_local_env = setup_dir.join("tokens.local.env");
388        Self {
389            setup_dir,
390            history_file,
391            endpoints_env,
392            endpoints_local_env,
393            tokens_env: Some(tokens_env),
394            tokens_local_env: Some(tokens_local_env),
395            jwts_env: None,
396            jwts_local_env: None,
397        }
398    }
399
400    pub fn graphql(setup_dir: PathBuf, history_override: Option<&Path>) -> Self {
401        let history_file =
402            crate::history::resolve_history_file(&setup_dir, history_override, ".gql_history");
403        let endpoints_env = setup_dir.join("endpoints.env");
404        let endpoints_local_env = setup_dir.join("endpoints.local.env");
405        let jwts_env = setup_dir.join("jwts.env");
406        let jwts_local_env = setup_dir.join("jwts.local.env");
407        Self {
408            setup_dir,
409            history_file,
410            endpoints_env,
411            endpoints_local_env,
412            tokens_env: None,
413            tokens_local_env: None,
414            jwts_env: Some(jwts_env),
415            jwts_local_env: Some(jwts_local_env),
416        }
417    }
418
419    pub fn grpc(setup_dir: PathBuf, history_override: Option<&Path>) -> Self {
420        let history_file =
421            crate::history::resolve_history_file(&setup_dir, history_override, ".grpc_history");
422        let endpoints_env = setup_dir.join("endpoints.env");
423        let endpoints_local_env = setup_dir.join("endpoints.local.env");
424        let tokens_env = setup_dir.join("tokens.env");
425        let tokens_local_env = setup_dir.join("tokens.local.env");
426        Self {
427            setup_dir,
428            history_file,
429            endpoints_env,
430            endpoints_local_env,
431            tokens_env: Some(tokens_env),
432            tokens_local_env: Some(tokens_local_env),
433            jwts_env: None,
434            jwts_local_env: None,
435        }
436    }
437
438    pub fn websocket(setup_dir: PathBuf, history_override: Option<&Path>) -> Self {
439        let history_file =
440            crate::history::resolve_history_file(&setup_dir, history_override, ".ws_history");
441        let endpoints_env = setup_dir.join("endpoints.env");
442        let endpoints_local_env = setup_dir.join("endpoints.local.env");
443        let tokens_env = setup_dir.join("tokens.env");
444        let tokens_local_env = setup_dir.join("tokens.local.env");
445        Self {
446            setup_dir,
447            history_file,
448            endpoints_env,
449            endpoints_local_env,
450            tokens_env: Some(tokens_env),
451            tokens_local_env: Some(tokens_local_env),
452            jwts_env: None,
453            jwts_local_env: None,
454        }
455    }
456
457    pub fn endpoints_files(&self) -> Vec<&Path> {
458        if self.endpoints_env.is_file() || self.endpoints_local_env.is_file() {
459            vec![&self.endpoints_env, &self.endpoints_local_env]
460        } else {
461            Vec::new()
462        }
463    }
464
465    pub fn tokens_files(&self) -> Vec<&Path> {
466        let mut files: Vec<&Path> = Vec::new();
467        if let Some(tokens_env) = self.tokens_env.as_deref() {
468            files.push(tokens_env);
469        }
470        if let Some(tokens_local) = self.tokens_local_env.as_deref() {
471            files.push(tokens_local);
472        }
473
474        if files.iter().any(|path| path.is_file()) {
475            files
476        } else {
477            Vec::new()
478        }
479    }
480
481    pub fn jwts_files(&self) -> Vec<&Path> {
482        let mut files: Vec<&Path> = Vec::new();
483        if let Some(jwts_env) = self.jwts_env.as_deref() {
484            files.push(jwts_env);
485        }
486        if let Some(jwts_local) = self.jwts_local_env.as_deref() {
487            files.push(jwts_local);
488        }
489
490        if files.iter().any(|path| path.is_file()) {
491            files
492        } else {
493            Vec::new()
494        }
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use pretty_assertions::assert_eq;
502
503    use tempfile::TempDir;
504
505    fn write_file(path: &Path, contents: &str) {
506        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
507        std::fs::write(path, contents).expect("write");
508    }
509
510    #[test]
511    fn config_rest_call_falls_back_to_upwards_setup_rest() {
512        let tmp = TempDir::new().expect("tmp");
513        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
514
515        write_file(
516            &root.join("setup/rest/endpoints.env"),
517            "export REST_URL_LOCAL=http://x\n",
518        );
519        write_file(
520            &root.join("tests/requests/health.request.json"),
521            r#"{"method":"GET","path":"/health"}"#,
522        );
523
524        let setup_dir = resolve_rest_setup_dir_for_call(
525            &root,
526            &root,
527            &root.join("tests/requests/health.request.json"),
528            None,
529        )
530        .expect("resolve");
531
532        assert_eq!(setup_dir, root.join("setup/rest"));
533    }
534
535    #[test]
536    fn config_rest_call_explicit_config_dir_wins() {
537        let tmp_root = TempDir::new().expect("tmp root");
538        let root = std::fs::canonicalize(tmp_root.path()).expect("root abs");
539
540        let tmp_cfg = TempDir::new().expect("tmp cfg");
541        let cfg_root = std::fs::canonicalize(tmp_cfg.path()).expect("cfg abs");
542        std::fs::create_dir_all(cfg_root.join("custom/rest")).expect("mkdir");
543
544        write_file(
545            &cfg_root.join("req/health.request.json"),
546            r#"{"method":"GET","path":"/health"}"#,
547        );
548
549        let setup_dir = resolve_rest_setup_dir_for_call(
550            &root,
551            &root,
552            &cfg_root.join("req/health.request.json"),
553            Some(&cfg_root.join("custom/rest")),
554        )
555        .expect("resolve");
556
557        assert_eq!(setup_dir, cfg_root.join("custom/rest"));
558    }
559
560    #[test]
561    fn endpoints_files_includes_local_when_env_missing() {
562        let tmp = TempDir::new().expect("tmp");
563        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
564        let setup = root.join("setup/rest");
565
566        write_file(
567            &setup.join("endpoints.local.env"),
568            "export REST_URL_LOCAL=http://localhost:1234\n",
569        );
570
571        let resolved = ResolvedSetup::rest(setup, None);
572        let files = resolved.endpoints_files();
573
574        assert_eq!(
575            files,
576            vec![
577                resolved.endpoints_env.as_path(),
578                resolved.endpoints_local_env.as_path()
579            ]
580        );
581    }
582
583    #[test]
584    fn tokens_files_handles_missing_local_path_without_panicking() {
585        let tmp = TempDir::new().expect("tmp");
586        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
587        let setup = root.join("setup/rest");
588
589        let mut resolved = ResolvedSetup::rest(setup, None);
590        let tokens_env = resolved.tokens_env.as_ref().expect("tokens env").clone();
591        write_file(&tokens_env, "REST_TOKEN_DEFAULT=abc\n");
592        resolved.tokens_local_env = None;
593
594        let files: Vec<PathBuf> = resolved
595            .tokens_files()
596            .into_iter()
597            .map(Path::to_path_buf)
598            .collect();
599
600        assert_eq!(files, vec![tokens_env]);
601    }
602
603    #[test]
604    fn jwts_files_handles_missing_local_path_without_panicking() {
605        let tmp = TempDir::new().expect("tmp");
606        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
607        let setup = root.join("setup/graphql");
608
609        let mut resolved = ResolvedSetup::graphql(setup, None);
610        let jwts_env = resolved.jwts_env.as_ref().expect("jwts env").clone();
611        write_file(&jwts_env, "GQL_JWT_DEFAULT=abc\n");
612        resolved.jwts_local_env = None;
613
614        let files: Vec<PathBuf> = resolved
615            .jwts_files()
616            .into_iter()
617            .map(Path::to_path_buf)
618            .collect();
619
620        assert_eq!(files, vec![jwts_env]);
621    }
622
623    #[test]
624    fn config_rest_call_falls_back_to_invocation_dir() {
625        let tmp_root = TempDir::new().expect("tmp root");
626        let root = std::fs::canonicalize(tmp_root.path()).expect("root abs");
627
628        std::fs::create_dir_all(root.join("setup/rest")).expect("mkdir");
629
630        let tmp_other = TempDir::new().expect("tmp other");
631        let other = std::fs::canonicalize(tmp_other.path()).expect("other abs");
632        write_file(
633            &other.join("place/health.request.json"),
634            r#"{"method":"GET","path":"/health"}"#,
635        );
636
637        let setup_dir = resolve_rest_setup_dir_for_call(
638            &root,
639            &root,
640            &other.join("place/health.request.json"),
641            None,
642        )
643        .expect("resolve");
644
645        assert_eq!(setup_dir, root.join("setup/rest"));
646    }
647
648    #[test]
649    fn config_gql_call_falls_back_to_setup_graphql_in_invocation_dir() {
650        let tmp = TempDir::new().expect("tmp");
651        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
652
653        write_file(
654            &root.join("setup/graphql/endpoints.env"),
655            "export GQL_URL_LOCAL=http://x\n",
656        );
657        write_file(
658            &root.join("operations/countries.graphql"),
659            "query { __typename }\n",
660        );
661
662        let setup_dir = resolve_gql_setup_dir_for_call(
663            &root,
664            &root,
665            Some(&root.join("operations/countries.graphql")),
666            None,
667        )
668        .expect("resolve");
669
670        assert_eq!(setup_dir, root.join("setup/graphql"));
671    }
672
673    #[test]
674    fn config_gql_schema_discovers_schema_env_upwards() {
675        let tmp = TempDir::new().expect("tmp");
676        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
677
678        write_file(
679            &root.join("setup/graphql/schema.env"),
680            "export GQL_SCHEMA_FILE=schema.gql\n",
681        );
682        std::fs::create_dir_all(root.join("setup/graphql/ops")).expect("mkdir");
683
684        let setup_dir =
685            resolve_gql_setup_dir_for_schema(&root, &root, Some(&root.join("setup/graphql/ops")))
686                .expect("resolve");
687
688        assert_eq!(setup_dir, root.join("setup/graphql"));
689    }
690
691    #[test]
692    fn config_grpc_call_falls_back_to_setup_grpc_in_invocation_dir() {
693        let tmp = TempDir::new().expect("tmp");
694        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
695
696        write_file(
697            &root.join("setup/grpc/endpoints.env"),
698            "export GRPC_URL_LOCAL=127.0.0.1:50051\n",
699        );
700        write_file(
701            &root.join("requests/health.grpc.json"),
702            r#"{"method":"health.HealthService/Check","body":{}}"#,
703        );
704
705        let setup_dir = resolve_grpc_setup_dir_for_call(
706            &root,
707            &root,
708            &root.join("requests/health.grpc.json"),
709            None,
710        )
711        .expect("resolve");
712
713        assert_eq!(setup_dir, root.join("setup/grpc"));
714    }
715
716    #[test]
717    fn resolved_setup_grpc_uses_grpc_history_default() {
718        let tmp = TempDir::new().expect("tmp");
719        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
720        let setup = root.join("setup/grpc");
721        std::fs::create_dir_all(&setup).expect("mkdir");
722
723        let resolved = ResolvedSetup::grpc(setup.clone(), None);
724        assert_eq!(resolved.history_file, setup.join(".grpc_history"));
725        assert!(resolved.tokens_env.is_some());
726        assert!(resolved.tokens_local_env.is_some());
727    }
728
729    #[test]
730    fn config_websocket_call_falls_back_to_setup_websocket_in_invocation_dir() {
731        let tmp = TempDir::new().expect("tmp");
732        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
733
734        write_file(
735            &root.join("setup/websocket/endpoints.env"),
736            "WS_URL_LOCAL=ws://127.0.0.1:9001/ws\n",
737        );
738        write_file(
739            &root.join("requests/health.ws.json"),
740            r#"{"url":"ws://127.0.0.1:9001/ws","steps":[{"type":"send","text":"ping"},{"type":"receive"}]}"#,
741        );
742
743        let setup_dir = resolve_websocket_setup_dir_for_call(
744            &root,
745            &root,
746            &root.join("requests/health.ws.json"),
747            None,
748        )
749        .expect("resolve");
750
751        assert_eq!(setup_dir, root.join("setup/websocket"));
752    }
753
754    #[test]
755    fn resolved_setup_websocket_uses_ws_history_default() {
756        let tmp = TempDir::new().expect("tmp");
757        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
758        let setup = root.join("setup/websocket");
759        std::fs::create_dir_all(&setup).expect("mkdir");
760
761        let resolved = ResolvedSetup::websocket(setup.clone(), None);
762        assert_eq!(resolved.history_file, setup.join(".ws_history"));
763        assert!(resolved.tokens_env.is_some());
764        assert!(resolved.tokens_local_env.is_some());
765    }
766}