Skip to main content

ryra_test/
test_toml.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6
7/// Parsed test.toml — the unified test definition format.
8#[derive(Debug, Deserialize)]
9pub struct TestToml {
10    #[serde(default)]
11    pub test: Option<TestMeta>,
12    #[serde(default)]
13    pub setup: Option<SetupSection>,
14    #[serde(default)]
15    pub tests: Vec<TestDef>,
16    #[serde(default)]
17    pub steps: Vec<StepDef>,
18}
19
20#[derive(Debug, Deserialize)]
21pub struct TestMeta {
22    pub name: Option<String>,
23    #[serde(default)]
24    pub browser: bool,
25    /// Optional RAM override (MB). When set, bypasses auto-calculation from
26    /// service requirements. Use for tests that run many services and need
27    /// more headroom than the sum of individual recommendations.
28    pub ram: Option<u32>,
29    /// Declares that this test performs privileged operations (shells out to
30    /// `sudo`). When true, the bare runner acquires sudo credentials once up
31    /// front so captured, non-TTY steps don't fail trying to prompt mid-run.
32    /// The runner already auto-detects the common case — writing `*.internal`
33    /// hostnames to `/etc/hosts` for OIDC/HTTPS URLs — so most tests never set
34    /// this; it's the escape hatch for any *other* sudo a test needs.
35    #[serde(default)]
36    pub requires_sudo: bool,
37}
38
39#[derive(Debug, Deserialize)]
40pub struct SetupSection {
41    #[serde(default)]
42    pub services: Vec<String>,
43    #[serde(default)]
44    pub quadlets: Vec<String>,
45}
46
47/// A single named test within a test.toml file.
48///
49/// Two shapes are accepted for backwards compatibility during the
50/// [[tests]]-array migration:
51///
52/// - **Multi-step (new)**: `steps` non-empty; `run` unset. Produces a
53///   lifecycle-style execution reading the given steps directly.
54/// - **Shell (legacy)**: `run` set; `steps` empty. Relies on `[setup]`
55///   at the file level to deploy services before running `run`.
56///
57/// Exactly one of `run` / `steps` must be present — validated at parse time.
58#[derive(Debug, Clone, Deserialize)]
59pub struct TestDef {
60    pub name: String,
61    /// Legacy: a single shell command run after `[setup]` services deploy.
62    #[serde(default)]
63    pub run: Option<String>,
64    /// New: a sequence of lifecycle steps (add / wait / http / shell / …).
65    #[serde(default)]
66    pub steps: Vec<StepDef>,
67    #[serde(default = "default_timeout")]
68    pub timeout: u64,
69    #[serde(default)]
70    pub env: BTreeMap<String, String>,
71    /// Needs a browser VM image (for Playwright steps). Can also be set
72    /// at the file level via `[test] browser = true`.
73    #[serde(default)]
74    pub browser: bool,
75    /// Per-test RAM override (MB). File-level `[test] ram` still works.
76    pub ram: Option<u32>,
77    /// Per-test `requires_sudo`. File-level `[test] requires_sudo` still works;
78    /// either being true marks the test as needing elevated privileges.
79    #[serde(default)]
80    pub requires_sudo: bool,
81}
82
83fn default_timeout() -> u64 {
84    30
85}
86
87fn default_add_timeout() -> u64 {
88    300
89}
90
91fn default_http_status() -> u16 {
92    200
93}
94
95fn default_content_type() -> String {
96    "application/json".into()
97}
98
99/// HTTP method for the `http` test step. Kept as a typed enum so parsing
100/// rejects typos at the boundary (per CLAUDE.md: enums over strings).
101#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "lowercase")]
103pub enum HttpMethod {
104    #[default]
105    Get,
106    Post,
107    Put,
108    Delete,
109}
110
111impl HttpMethod {
112    /// Upper-case verb for curl's `-X` flag.
113    pub fn as_curl_arg(self) -> &'static str {
114        match self {
115            HttpMethod::Get => "GET",
116            HttpMethod::Post => "POST",
117            HttpMethod::Put => "PUT",
118            HttpMethod::Delete => "DELETE",
119        }
120    }
121}
122
123/// Retry configuration for run steps. The runner re-executes the command
124/// up to `attempts` times, sleeping `interval` seconds between tries.
125#[derive(Debug, Clone, Deserialize)]
126pub struct PollConfig {
127    /// Seconds between retries.
128    pub interval: u64,
129    /// Maximum number of attempts before giving up.
130    pub attempts: u64,
131}
132
133/// A lifecycle test step — serde deserializes directly into the correct
134/// variant based on the `action` field. Invalid field combinations are
135/// rejected at parse time rather than runtime.
136#[derive(Debug, Clone, Deserialize)]
137#[serde(tag = "action", rename_all = "lowercase")]
138pub enum StepDef {
139    Add {
140        service: String,
141        #[serde(default)]
142        args: Option<String>,
143        #[serde(default)]
144        env: BTreeMap<String, String>,
145        #[serde(default = "default_add_timeout")]
146        timeout: u64,
147        /// Set by local-project discovery (`--project`): add by this path
148        /// instead of by registry name, so the project's own
149        /// `service.toml` resolves. Never set from test.toml; `service`
150        /// remains the real registered name for every other use.
151        #[serde(skip)]
152        project_path: Option<std::path::PathBuf>,
153    },
154    Remove {
155        service: String,
156    },
157    Wait {
158        service: String,
159        #[serde(default = "default_timeout")]
160        timeout: u64,
161    },
162    /// Shell command step. Fails the test on non-zero exit code.
163    Shell {
164        name: String,
165        run: String,
166        #[serde(default = "default_timeout")]
167        timeout: u64,
168        /// Optional retry configuration. When set, the runner re-executes
169        /// the command on failure, up to `attempts` times.
170        #[serde(default)]
171        poll: Option<PollConfig>,
172    },
173    /// HTTP request step. Sends a request and checks the response status code.
174    /// The URL supports shell variable expansion (e.g., `$SERVICE_PORT_HTTP`)
175    /// after sourcing service `.env` files. Follows redirects automatically.
176    Http {
177        #[serde(default)]
178        name: Option<String>,
179        url: String,
180        #[serde(default)]
181        method: HttpMethod,
182        /// Request body for POST/PUT. Shell heredoc-safe: arbitrary bytes
183        /// are supported including quotes and newlines.
184        #[serde(default)]
185        body: Option<String>,
186        /// Content-Type header for requests with a body. Defaults to
187        /// application/json since most API triggers we use ship JSON.
188        #[serde(default = "default_content_type")]
189        content_type: String,
190        /// Extra request headers (e.g., `apikey`, `Authorization`). Values
191        /// support shell variable expansion after `.env` sourcing.
192        #[serde(default)]
193        headers: BTreeMap<String, String>,
194        #[serde(default = "default_http_status")]
195        status: u16,
196        /// When set, only source this service's `.env` file (needed when
197        /// multiple services define the same port variable).
198        #[serde(default)]
199        service: Option<String>,
200        #[serde(default)]
201        poll: Option<PollConfig>,
202        #[serde(default = "default_timeout")]
203        timeout: u64,
204    },
205    /// Playwright browser test step.
206    Playwright {
207        #[serde(default)]
208        name: Option<String>,
209        spec: String,
210        #[serde(default)]
211        env: BTreeMap<String, String>,
212        #[serde(default = "default_browser_timeout")]
213        timeout: u64,
214    },
215    /// Inbucket mail-delivery assertion. Polls inbucket's `/api/v1/mailbox/
216    /// <mailbox>` endpoint until a non-empty response arrives; when
217    /// `contains` is set, additionally requires that substring in the raw
218    /// JSON body. Collapses the 8-line port-discovery + curl-poll pattern
219    /// that previously lived in every SMTP test into one step.
220    Mail {
221        #[serde(default)]
222        name: Option<String>,
223        /// Local-part of the recipient address (`smtptest` for `smtptest@example.com`).
224        mailbox: String,
225        /// Optional substring required in the response body. Matches
226        /// against the raw inbucket JSON, which includes subject + body.
227        #[serde(default)]
228        contains: Option<String>,
229        /// Retry config. Defaults favour short SMTP mail delivery; apps
230        /// with async mail queues (twenty, supabase) should widen these.
231        #[serde(default = "default_mail_poll")]
232        poll: PollConfig,
233        #[serde(default = "default_timeout")]
234        timeout: u64,
235    },
236}
237
238fn default_mail_poll() -> PollConfig {
239    PollConfig {
240        interval: 2,
241        attempts: 30,
242    }
243}
244
245fn default_browser_timeout() -> u64 {
246    120
247}
248
249impl std::fmt::Display for StepDef {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match self {
252            StepDef::Add { service, .. } => write!(f, "add {service}"),
253            StepDef::Remove { service } => write!(f, "remove {service}"),
254            StepDef::Wait { service, .. } => write!(f, "wait {service}"),
255            StepDef::Shell { name, .. } => write!(f, "shell: {name}"),
256            StepDef::Http { name, url, .. } => {
257                write!(f, "http: {}", name.as_deref().unwrap_or(url))
258            }
259            StepDef::Playwright { name, spec, .. } => {
260                write!(f, "browser: {}", name.as_deref().unwrap_or(spec))
261            }
262            StepDef::Mail { name, mailbox, .. } => {
263                write!(f, "mail: {}", name.as_deref().unwrap_or(mailbox))
264            }
265        }
266    }
267}
268
269impl StepDef {
270    /// The service name referenced by this step, if any.
271    pub fn service(&self) -> Option<&str> {
272        match self {
273            StepDef::Add { service, .. }
274            | StepDef::Remove { service }
275            | StepDef::Wait { service, .. } => Some(service),
276            _ => None,
277        }
278    }
279
280    /// Whether this step is a setup step (vs. a test/assertion step).
281    /// Used by `--retest` to skip setup and only re-run test steps.
282    pub fn is_setup(&self) -> bool {
283        matches!(
284            self,
285            StepDef::Add { .. } | StepDef::Remove { .. } | StepDef::Wait { .. }
286        )
287    }
288
289    /// Human-readable name for this step (used in output).
290    pub fn step_name(&self) -> String {
291        format!("{self}")
292    }
293
294    /// Multi-line description for `--list -v`. Shows every field that
295    /// meaningfully changes behaviour (args, env, headers, body, …).
296    /// The caller indents each returned line.
297    pub fn describe(&self) -> Vec<String> {
298        let mut lines = Vec::new();
299        match self {
300            StepDef::Add {
301                service,
302                args,
303                env,
304                timeout,
305                ..
306            } => {
307                let args_s = args
308                    .as_deref()
309                    .filter(|s| !s.is_empty())
310                    .map(|a| format!(" {a}"))
311                    .unwrap_or_default();
312                lines.push(format!("ryra add {service}{args_s}  (timeout={timeout}s)"));
313                for (k, v) in env {
314                    lines.push(format!("  env {k}={v}"));
315                }
316            }
317            StepDef::Remove { service } => lines.push(format!("ryra remove --purge {service}")),
318            StepDef::Wait { service, timeout } => {
319                lines.push(format!("wait for {service}.service  (timeout={timeout}s)"));
320            }
321            StepDef::Shell {
322                name,
323                run,
324                timeout,
325                poll,
326            } => {
327                let poll_s = match poll {
328                    Some(p) => {
329                        format!(
330                            "  poll={{interval={}s, attempts={}}}",
331                            p.interval, p.attempts
332                        )
333                    }
334                    None => String::new(),
335                };
336                lines.push(format!("shell '{name}'  (timeout={timeout}s{poll_s})"));
337                for l in run.trim().lines() {
338                    lines.push(format!("  | {l}"));
339                }
340            }
341            StepDef::Http {
342                name,
343                url,
344                method,
345                body,
346                content_type,
347                headers,
348                status,
349                service,
350                poll,
351                timeout,
352            } => {
353                let label = name.as_deref().unwrap_or("(anon)");
354                let verb = method.as_curl_arg();
355                lines.push(format!(
356                    "http '{label}': {verb} {url}  (expect {status}, timeout={timeout}s)"
357                ));
358                if let Some(svc) = service {
359                    lines.push(format!("  env-source: {svc}/.env"));
360                }
361                for (k, v) in headers {
362                    lines.push(format!("  header {k}: {v}"));
363                }
364                if let Some(b) = body {
365                    lines.push(format!("  content-type: {content_type}"));
366                    for l in b.trim().lines() {
367                        lines.push(format!("  body> {l}"));
368                    }
369                }
370                if let Some(p) = poll {
371                    lines.push(format!(
372                        "  poll: every {}s, up to {} attempts",
373                        p.interval, p.attempts
374                    ));
375                }
376            }
377            StepDef::Playwright {
378                name,
379                spec,
380                env,
381                timeout,
382            } => {
383                let label = name.as_deref().unwrap_or(spec);
384                lines.push(format!(
385                    "playwright '{label}': spec={spec}  (timeout={timeout}s)"
386                ));
387                for (k, v) in env {
388                    lines.push(format!("  env {k}={v}"));
389                }
390            }
391            StepDef::Mail {
392                name,
393                mailbox,
394                contains,
395                poll,
396                timeout,
397            } => {
398                let label = name.as_deref().unwrap_or(mailbox);
399                lines.push(format!(
400                    "mail '{label}': mailbox={mailbox}  (timeout={timeout}s)"
401                ));
402                if let Some(c) = contains {
403                    lines.push(format!("  contains: {c}"));
404                }
405                lines.push(format!(
406                    "  poll: every {}s, up to {} attempts",
407                    poll.interval, poll.attempts
408                ));
409            }
410        }
411        lines
412    }
413}
414
415impl TestToml {
416    /// Read and deserialize a test.toml file, then validate it.
417    pub fn parse(path: &Path) -> Result<Self> {
418        let content = std::fs::read_to_string(path)
419            .with_context(|| format!("failed to read test.toml at {}", path.display()))?;
420        let parsed: Self = toml::from_str(&content)
421            .with_context(|| format!("failed to parse test.toml at {}", path.display()))?;
422        parsed.validate(path)?;
423        Ok(parsed)
424    }
425
426    /// Validate structural invariants after deserialization.
427    ///
428    /// Most field-level validation is handled by serde (the tagged enum
429    /// rejects missing required fields at parse time). This only checks
430    /// cross-field invariants that serde can't express.
431    pub fn validate(&self, path: &Path) -> Result<()> {
432        let ctx = path.display();
433
434        // Top-level [[tests]] coexists with [[steps]] ONLY if all [[tests]]
435        // are new-format (each brings its own `steps`). The legacy shape
436        // (shell-style `run`-based tests with a shared [setup]) remains
437        // mutually exclusive with top-level [[steps]].
438        let has_legacy_run_tests = self
439            .tests
440            .iter()
441            .any(|t| t.run.is_some() && t.steps.is_empty());
442        if has_legacy_run_tests && !self.steps.is_empty() {
443            anyhow::bail!(
444                "{ctx}: test.toml cannot mix [setup]+[[tests]] (legacy shell) with top-level [[steps]] — \
445                 migrate to the new [[tests]] + [[tests.steps]] format instead",
446            );
447        }
448
449        for t in &self.tests {
450            let has_run = t.run.is_some();
451            let has_steps = !t.steps.is_empty();
452            if has_run == has_steps {
453                anyhow::bail!(
454                    "{ctx}: test '{}' must set exactly one of `run` or `steps` \
455                     (got run={}, steps={})",
456                    t.name,
457                    has_run,
458                    has_steps,
459                );
460            }
461        }
462
463        Ok(())
464    }
465
466    /// True if this is a lifecycle test (uses [[steps]] instead of [[tests]]).
467    pub fn is_lifecycle(&self) -> bool {
468        !self.steps.is_empty()
469    }
470
471    /// True if this test requires a browser VM image.
472    pub fn needs_browser(&self) -> bool {
473        self.test.as_ref().is_some_and(|t| t.browser)
474    }
475
476    /// Explicit RAM override (MB) from [test] metadata, if set.
477    pub fn ram_override(&self) -> Option<u32> {
478        self.test.as_ref().and_then(|t| t.ram)
479    }
480
481    /// File-level `[test] requires_sudo` flag.
482    pub fn requires_sudo(&self) -> bool {
483        self.test.as_ref().is_some_and(|t| t.requires_sudo)
484    }
485
486    /// The test name from [test] metadata, or the file stem as a fallback.
487    pub fn name_or_default(&self, path: &Path) -> String {
488        if let Some(ref meta) = self.test
489            && let Some(ref name) = meta.name
490        {
491            return name.clone();
492        }
493        path.file_stem()
494            .and_then(|s| s.to_str())
495            .unwrap_or("unknown")
496            .to_string()
497    }
498
499    /// All services referenced: from setup.services + any `add` steps.
500    pub fn referenced_services(&self) -> Vec<String> {
501        let mut services: Vec<String> = self
502            .setup
503            .as_ref()
504            .map_or_else(Vec::new, |s| s.services.clone());
505
506        for step in &self.steps {
507            if let StepDef::Add { service, .. } = step
508                && !services.contains(service)
509            {
510                services.push(service.clone());
511            }
512        }
513
514        services
515    }
516
517    /// Quadlet files declared in [setup].
518    pub fn quadlet_files(&self) -> Vec<String> {
519        self.setup
520            .as_ref()
521            .map_or_else(Vec::new, |s| s.quadlets.clone())
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use std::io::Write as _;
529
530    fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
531        let dir = tempfile::tempdir().expect("tempdir");
532        let path = dir.path().join("test.toml");
533        let mut f = std::fs::File::create(&path).expect("create");
534        f.write_all(content.as_bytes()).expect("write");
535        (dir, path)
536    }
537
538    #[test]
539    fn reject_mixed_tests_and_steps() {
540        let toml = r#"
541[[tests]]
542name = "foo"
543run = "true"
544
545[[steps]]
546action = "add"
547service = "bar"
548"#;
549        let (_dir, path) = write_temp(toml);
550        let result = TestToml::parse(&path);
551        assert!(result.is_err(), "expected error for mixed tests+steps");
552        let msg = format!("{:#}", result.unwrap_err());
553        assert!(msg.contains("[[tests]]") || msg.contains("[[steps]]"));
554    }
555
556    #[test]
557    fn name_from_metadata() {
558        let toml = r#"
559[test]
560name = "my explicit name"
561
562[[tests]]
563name = "check"
564run = "true"
565"#;
566        let (_dir, path) = write_temp(toml);
567        let parsed = TestToml::parse(&path).expect("parse");
568        assert_eq!(parsed.name_or_default(&path), "my explicit name");
569    }
570
571    #[test]
572    fn name_from_filename() {
573        let toml = r#"
574[[tests]]
575name = "check"
576run = "true"
577"#;
578        let dir = tempfile::tempdir().expect("tempdir");
579        let path = dir.path().join("immich-sso.toml");
580        std::fs::write(&path, toml).expect("write");
581        let parsed = TestToml::parse(&path).expect("parse");
582        assert_eq!(parsed.name_or_default(&path), "immich-sso");
583    }
584
585    #[test]
586    fn browser_step_requires_spec() {
587        let toml = r#"
588[[steps]]
589action = "playwright"
590"#;
591        let (_dir, path) = write_temp(toml);
592        let result = TestToml::parse(&path);
593        assert!(result.is_err());
594        let msg = format!("{:#}", result.unwrap_err());
595        assert!(msg.contains("spec") || msg.contains("missing field"));
596    }
597
598    #[test]
599    fn run_step_rejects_missing_name() {
600        let toml = r#"
601[[steps]]
602action = "shell"
603run = "true"
604"#;
605        let (_dir, path) = write_temp(toml);
606        let result = TestToml::parse(&path);
607        assert!(result.is_err(), "run step without 'name' should fail");
608    }
609
610    #[test]
611    fn add_step_default_timeout() {
612        let toml = r#"
613[[steps]]
614action = "add"
615service = "whoami"
616"#;
617        let (_dir, path) = write_temp(toml);
618        let parsed = TestToml::parse(&path).expect("parse");
619        if let StepDef::Add { timeout, .. } = parsed.steps[0] {
620            assert_eq!(timeout, 300);
621        } else {
622            panic!("expected Add step");
623        }
624    }
625
626    #[test]
627    fn http_step_defaults() {
628        let toml = r#"
629[[steps]]
630action = "http"
631url = "http://localhost:8080"
632"#;
633        let (_dir, path) = write_temp(toml);
634        let parsed = TestToml::parse(&path).expect("parse");
635        if let StepDef::Http {
636            status, timeout, ..
637        } = parsed.steps[0]
638        {
639            assert_eq!(status, 200);
640            assert_eq!(timeout, 30);
641        } else {
642            panic!("expected Http step");
643        }
644    }
645
646    #[test]
647    fn mail_step_defaults() {
648        let toml = r#"
649[[steps]]
650action = "mail"
651mailbox = "smtptest"
652"#;
653        let (_dir, path) = write_temp(toml);
654        let parsed = TestToml::parse(&path).expect("parse");
655        if let StepDef::Mail {
656            ref contains,
657            ref poll,
658            timeout,
659            ..
660        } = parsed.steps[0]
661        {
662            assert!(contains.is_none(), "contains defaults to None");
663            assert_eq!(poll.interval, 2, "default poll interval");
664            assert_eq!(poll.attempts, 30, "default poll attempts");
665            assert_eq!(timeout, 30);
666        } else {
667            panic!("expected Mail step");
668        }
669    }
670
671    #[test]
672    fn is_setup_classification() {
673        let toml = r#"
674[[steps]]
675action = "add"
676service = "whoami"
677
678[[steps]]
679action = "remove"
680service = "whoami"
681
682[[steps]]
683action = "wait"
684service = "whoami"
685
686[[steps]]
687action = "shell"
688name = "check"
689run = "true"
690
691[[steps]]
692action = "http"
693url = "http://localhost:8080"
694
695[[steps]]
696action = "playwright"
697spec = "test.spec.ts"
698"#;
699        let (_dir, path) = write_temp(toml);
700        let parsed = TestToml::parse(&path).expect("parse");
701        assert!(parsed.steps[0].is_setup(), "add should be setup");
702        assert!(parsed.steps[1].is_setup(), "remove should be setup");
703        assert!(parsed.steps[2].is_setup(), "wait should be setup");
704        assert!(!parsed.steps[3].is_setup(), "shell should not be setup");
705        assert!(!parsed.steps[4].is_setup(), "http should not be setup");
706        assert!(
707            !parsed.steps[5].is_setup(),
708            "playwright should not be setup"
709        );
710    }
711}