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    },
148    Remove {
149        service: String,
150    },
151    Wait {
152        service: String,
153        #[serde(default = "default_timeout")]
154        timeout: u64,
155    },
156    /// Shell command step. Fails the test on non-zero exit code.
157    Shell {
158        name: String,
159        run: String,
160        #[serde(default = "default_timeout")]
161        timeout: u64,
162        /// Optional retry configuration. When set, the runner re-executes
163        /// the command on failure, up to `attempts` times.
164        #[serde(default)]
165        poll: Option<PollConfig>,
166    },
167    /// HTTP request step. Sends a request and checks the response status code.
168    /// The URL supports shell variable expansion (e.g., `$SERVICE_PORT_HTTP`)
169    /// after sourcing service `.env` files. Follows redirects automatically.
170    Http {
171        #[serde(default)]
172        name: Option<String>,
173        url: String,
174        #[serde(default)]
175        method: HttpMethod,
176        /// Request body for POST/PUT. Shell heredoc-safe: arbitrary bytes
177        /// are supported including quotes and newlines.
178        #[serde(default)]
179        body: Option<String>,
180        /// Content-Type header for requests with a body. Defaults to
181        /// application/json since most API triggers we use ship JSON.
182        #[serde(default = "default_content_type")]
183        content_type: String,
184        /// Extra request headers (e.g., `apikey`, `Authorization`). Values
185        /// support shell variable expansion after `.env` sourcing.
186        #[serde(default)]
187        headers: BTreeMap<String, String>,
188        #[serde(default = "default_http_status")]
189        status: u16,
190        /// When set, only source this service's `.env` file (needed when
191        /// multiple services define the same port variable).
192        #[serde(default)]
193        service: Option<String>,
194        #[serde(default)]
195        poll: Option<PollConfig>,
196        #[serde(default = "default_timeout")]
197        timeout: u64,
198    },
199    /// Playwright browser test step.
200    Playwright {
201        #[serde(default)]
202        name: Option<String>,
203        spec: String,
204        #[serde(default)]
205        env: BTreeMap<String, String>,
206        #[serde(default = "default_browser_timeout")]
207        timeout: u64,
208    },
209    /// Inbucket mail-delivery assertion. Polls inbucket's `/api/v1/mailbox/
210    /// <mailbox>` endpoint until a non-empty response arrives; when
211    /// `contains` is set, additionally requires that substring in the raw
212    /// JSON body. Collapses the 8-line port-discovery + curl-poll pattern
213    /// that previously lived in every SMTP test into one step.
214    Mail {
215        #[serde(default)]
216        name: Option<String>,
217        /// Local-part of the recipient address (`smtptest` for `smtptest@example.com`).
218        mailbox: String,
219        /// Optional substring required in the response body. Matches
220        /// against the raw inbucket JSON, which includes subject + body.
221        #[serde(default)]
222        contains: Option<String>,
223        /// Retry config. Defaults favour short SMTP mail delivery; apps
224        /// with async mail queues (twenty, supabase) should widen these.
225        #[serde(default = "default_mail_poll")]
226        poll: PollConfig,
227        #[serde(default = "default_timeout")]
228        timeout: u64,
229    },
230}
231
232fn default_mail_poll() -> PollConfig {
233    PollConfig {
234        interval: 2,
235        attempts: 30,
236    }
237}
238
239fn default_browser_timeout() -> u64 {
240    120
241}
242
243impl std::fmt::Display for StepDef {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        match self {
246            StepDef::Add { service, .. } => write!(f, "add {service}"),
247            StepDef::Remove { service } => write!(f, "remove {service}"),
248            StepDef::Wait { service, .. } => write!(f, "wait {service}"),
249            StepDef::Shell { name, .. } => write!(f, "shell: {name}"),
250            StepDef::Http { name, url, .. } => {
251                write!(f, "http: {}", name.as_deref().unwrap_or(url))
252            }
253            StepDef::Playwright { name, spec, .. } => {
254                write!(f, "browser: {}", name.as_deref().unwrap_or(spec))
255            }
256            StepDef::Mail { name, mailbox, .. } => {
257                write!(f, "mail: {}", name.as_deref().unwrap_or(mailbox))
258            }
259        }
260    }
261}
262
263impl StepDef {
264    /// The service name referenced by this step, if any.
265    pub fn service(&self) -> Option<&str> {
266        match self {
267            StepDef::Add { service, .. }
268            | StepDef::Remove { service }
269            | StepDef::Wait { service, .. } => Some(service),
270            _ => None,
271        }
272    }
273
274    /// Whether this step is a setup step (vs. a test/assertion step).
275    /// Used by `--retest` to skip setup and only re-run test steps.
276    pub fn is_setup(&self) -> bool {
277        matches!(
278            self,
279            StepDef::Add { .. } | StepDef::Remove { .. } | StepDef::Wait { .. }
280        )
281    }
282
283    /// Human-readable name for this step (used in output).
284    pub fn step_name(&self) -> String {
285        format!("{self}")
286    }
287
288    /// Multi-line description for `--list -v`. Shows every field that
289    /// meaningfully changes behaviour (args, env, headers, body, …).
290    /// The caller indents each returned line.
291    pub fn describe(&self) -> Vec<String> {
292        let mut lines = Vec::new();
293        match self {
294            StepDef::Add {
295                service,
296                args,
297                env,
298                timeout,
299            } => {
300                let args_s = args
301                    .as_deref()
302                    .filter(|s| !s.is_empty())
303                    .map(|a| format!(" {a}"))
304                    .unwrap_or_default();
305                lines.push(format!("ryra add {service}{args_s}  (timeout={timeout}s)"));
306                for (k, v) in env {
307                    lines.push(format!("  env {k}={v}"));
308                }
309            }
310            StepDef::Remove { service } => lines.push(format!("ryra remove --purge {service}")),
311            StepDef::Wait { service, timeout } => {
312                lines.push(format!("wait for {service}.service  (timeout={timeout}s)"));
313            }
314            StepDef::Shell {
315                name,
316                run,
317                timeout,
318                poll,
319            } => {
320                let poll_s = match poll {
321                    Some(p) => {
322                        format!(
323                            "  poll={{interval={}s, attempts={}}}",
324                            p.interval, p.attempts
325                        )
326                    }
327                    None => String::new(),
328                };
329                lines.push(format!("shell '{name}'  (timeout={timeout}s{poll_s})"));
330                for l in run.trim().lines() {
331                    lines.push(format!("  | {l}"));
332                }
333            }
334            StepDef::Http {
335                name,
336                url,
337                method,
338                body,
339                content_type,
340                headers,
341                status,
342                service,
343                poll,
344                timeout,
345            } => {
346                let label = name.as_deref().unwrap_or("(anon)");
347                let verb = method.as_curl_arg();
348                lines.push(format!(
349                    "http '{label}': {verb} {url}  (expect {status}, timeout={timeout}s)"
350                ));
351                if let Some(svc) = service {
352                    lines.push(format!("  env-source: {svc}/.env"));
353                }
354                for (k, v) in headers {
355                    lines.push(format!("  header {k}: {v}"));
356                }
357                if let Some(b) = body {
358                    lines.push(format!("  content-type: {content_type}"));
359                    for l in b.trim().lines() {
360                        lines.push(format!("  body> {l}"));
361                    }
362                }
363                if let Some(p) = poll {
364                    lines.push(format!(
365                        "  poll: every {}s, up to {} attempts",
366                        p.interval, p.attempts
367                    ));
368                }
369            }
370            StepDef::Playwright {
371                name,
372                spec,
373                env,
374                timeout,
375            } => {
376                let label = name.as_deref().unwrap_or(spec);
377                lines.push(format!(
378                    "playwright '{label}': spec={spec}  (timeout={timeout}s)"
379                ));
380                for (k, v) in env {
381                    lines.push(format!("  env {k}={v}"));
382                }
383            }
384            StepDef::Mail {
385                name,
386                mailbox,
387                contains,
388                poll,
389                timeout,
390            } => {
391                let label = name.as_deref().unwrap_or(mailbox);
392                lines.push(format!(
393                    "mail '{label}': mailbox={mailbox}  (timeout={timeout}s)"
394                ));
395                if let Some(c) = contains {
396                    lines.push(format!("  contains: {c}"));
397                }
398                lines.push(format!(
399                    "  poll: every {}s, up to {} attempts",
400                    poll.interval, poll.attempts
401                ));
402            }
403        }
404        lines
405    }
406}
407
408impl TestToml {
409    /// Read and deserialize a test.toml file, then validate it.
410    pub fn parse(path: &Path) -> Result<Self> {
411        let content = std::fs::read_to_string(path)
412            .with_context(|| format!("failed to read test.toml at {}", path.display()))?;
413        let parsed: Self = toml::from_str(&content)
414            .with_context(|| format!("failed to parse test.toml at {}", path.display()))?;
415        parsed.validate(path)?;
416        Ok(parsed)
417    }
418
419    /// Validate structural invariants after deserialization.
420    ///
421    /// Most field-level validation is handled by serde (the tagged enum
422    /// rejects missing required fields at parse time). This only checks
423    /// cross-field invariants that serde can't express.
424    pub fn validate(&self, path: &Path) -> Result<()> {
425        let ctx = path.display();
426
427        // Top-level [[tests]] coexists with [[steps]] ONLY if all [[tests]]
428        // are new-format (each brings its own `steps`). The legacy shape
429        // (shell-style `run`-based tests with a shared [setup]) remains
430        // mutually exclusive with top-level [[steps]].
431        let has_legacy_run_tests = self
432            .tests
433            .iter()
434            .any(|t| t.run.is_some() && t.steps.is_empty());
435        if has_legacy_run_tests && !self.steps.is_empty() {
436            anyhow::bail!(
437                "{ctx}: test.toml cannot mix [setup]+[[tests]] (legacy shell) with top-level [[steps]] — \
438                 migrate to the new [[tests]] + [[tests.steps]] format instead",
439            );
440        }
441
442        for t in &self.tests {
443            let has_run = t.run.is_some();
444            let has_steps = !t.steps.is_empty();
445            if has_run == has_steps {
446                anyhow::bail!(
447                    "{ctx}: test '{}' must set exactly one of `run` or `steps` \
448                     (got run={}, steps={})",
449                    t.name,
450                    has_run,
451                    has_steps,
452                );
453            }
454        }
455
456        Ok(())
457    }
458
459    /// True if this is a lifecycle test (uses [[steps]] instead of [[tests]]).
460    pub fn is_lifecycle(&self) -> bool {
461        !self.steps.is_empty()
462    }
463
464    /// True if this test requires a browser VM image.
465    pub fn needs_browser(&self) -> bool {
466        self.test.as_ref().is_some_and(|t| t.browser)
467    }
468
469    /// Explicit RAM override (MB) from [test] metadata, if set.
470    pub fn ram_override(&self) -> Option<u32> {
471        self.test.as_ref().and_then(|t| t.ram)
472    }
473
474    /// File-level `[test] requires_sudo` flag.
475    pub fn requires_sudo(&self) -> bool {
476        self.test.as_ref().is_some_and(|t| t.requires_sudo)
477    }
478
479    /// The test name from [test] metadata, or the file stem as a fallback.
480    pub fn name_or_default(&self, path: &Path) -> String {
481        if let Some(ref meta) = self.test
482            && let Some(ref name) = meta.name
483        {
484            return name.clone();
485        }
486        path.file_stem()
487            .and_then(|s| s.to_str())
488            .unwrap_or("unknown")
489            .to_string()
490    }
491
492    /// All services referenced: from setup.services + any `add` steps.
493    pub fn referenced_services(&self) -> Vec<String> {
494        let mut services: Vec<String> = self
495            .setup
496            .as_ref()
497            .map_or_else(Vec::new, |s| s.services.clone());
498
499        for step in &self.steps {
500            if let StepDef::Add { service, .. } = step
501                && !services.contains(service)
502            {
503                services.push(service.clone());
504            }
505        }
506
507        services
508    }
509
510    /// Quadlet files declared in [setup].
511    pub fn quadlet_files(&self) -> Vec<String> {
512        self.setup
513            .as_ref()
514            .map_or_else(Vec::new, |s| s.quadlets.clone())
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use std::io::Write as _;
522
523    fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
524        let dir = tempfile::tempdir().expect("tempdir");
525        let path = dir.path().join("test.toml");
526        let mut f = std::fs::File::create(&path).expect("create");
527        f.write_all(content.as_bytes()).expect("write");
528        (dir, path)
529    }
530
531    #[test]
532    fn reject_mixed_tests_and_steps() {
533        let toml = r#"
534[[tests]]
535name = "foo"
536run = "true"
537
538[[steps]]
539action = "add"
540service = "bar"
541"#;
542        let (_dir, path) = write_temp(toml);
543        let result = TestToml::parse(&path);
544        assert!(result.is_err(), "expected error for mixed tests+steps");
545        let msg = format!("{:#}", result.unwrap_err());
546        assert!(msg.contains("[[tests]]") || msg.contains("[[steps]]"));
547    }
548
549    #[test]
550    fn name_from_metadata() {
551        let toml = r#"
552[test]
553name = "my explicit name"
554
555[[tests]]
556name = "check"
557run = "true"
558"#;
559        let (_dir, path) = write_temp(toml);
560        let parsed = TestToml::parse(&path).expect("parse");
561        assert_eq!(parsed.name_or_default(&path), "my explicit name");
562    }
563
564    #[test]
565    fn name_from_filename() {
566        let toml = r#"
567[[tests]]
568name = "check"
569run = "true"
570"#;
571        let dir = tempfile::tempdir().expect("tempdir");
572        let path = dir.path().join("immich-sso.toml");
573        std::fs::write(&path, toml).expect("write");
574        let parsed = TestToml::parse(&path).expect("parse");
575        assert_eq!(parsed.name_or_default(&path), "immich-sso");
576    }
577
578    #[test]
579    fn browser_step_requires_spec() {
580        let toml = r#"
581[[steps]]
582action = "playwright"
583"#;
584        let (_dir, path) = write_temp(toml);
585        let result = TestToml::parse(&path);
586        assert!(result.is_err());
587        let msg = format!("{:#}", result.unwrap_err());
588        assert!(msg.contains("spec") || msg.contains("missing field"));
589    }
590
591    #[test]
592    fn run_step_rejects_missing_name() {
593        let toml = r#"
594[[steps]]
595action = "shell"
596run = "true"
597"#;
598        let (_dir, path) = write_temp(toml);
599        let result = TestToml::parse(&path);
600        assert!(result.is_err(), "run step without 'name' should fail");
601    }
602
603    #[test]
604    fn add_step_default_timeout() {
605        let toml = r#"
606[[steps]]
607action = "add"
608service = "whoami"
609"#;
610        let (_dir, path) = write_temp(toml);
611        let parsed = TestToml::parse(&path).expect("parse");
612        if let StepDef::Add { timeout, .. } = parsed.steps[0] {
613            assert_eq!(timeout, 300);
614        } else {
615            panic!("expected Add step");
616        }
617    }
618
619    #[test]
620    fn http_step_defaults() {
621        let toml = r#"
622[[steps]]
623action = "http"
624url = "http://localhost:8080"
625"#;
626        let (_dir, path) = write_temp(toml);
627        let parsed = TestToml::parse(&path).expect("parse");
628        if let StepDef::Http {
629            status, timeout, ..
630        } = parsed.steps[0]
631        {
632            assert_eq!(status, 200);
633            assert_eq!(timeout, 30);
634        } else {
635            panic!("expected Http step");
636        }
637    }
638
639    #[test]
640    fn mail_step_defaults() {
641        let toml = r#"
642[[steps]]
643action = "mail"
644mailbox = "smtptest"
645"#;
646        let (_dir, path) = write_temp(toml);
647        let parsed = TestToml::parse(&path).expect("parse");
648        if let StepDef::Mail {
649            ref contains,
650            ref poll,
651            timeout,
652            ..
653        } = parsed.steps[0]
654        {
655            assert!(contains.is_none(), "contains defaults to None");
656            assert_eq!(poll.interval, 2, "default poll interval");
657            assert_eq!(poll.attempts, 30, "default poll attempts");
658            assert_eq!(timeout, 30);
659        } else {
660            panic!("expected Mail step");
661        }
662    }
663
664    #[test]
665    fn is_setup_classification() {
666        let toml = r#"
667[[steps]]
668action = "add"
669service = "whoami"
670
671[[steps]]
672action = "remove"
673service = "whoami"
674
675[[steps]]
676action = "wait"
677service = "whoami"
678
679[[steps]]
680action = "shell"
681name = "check"
682run = "true"
683
684[[steps]]
685action = "http"
686url = "http://localhost:8080"
687
688[[steps]]
689action = "playwright"
690spec = "test.spec.ts"
691"#;
692        let (_dir, path) = write_temp(toml);
693        let parsed = TestToml::parse(&path).expect("parse");
694        assert!(parsed.steps[0].is_setup(), "add should be setup");
695        assert!(parsed.steps[1].is_setup(), "remove should be setup");
696        assert!(parsed.steps[2].is_setup(), "wait should be setup");
697        assert!(!parsed.steps[3].is_setup(), "shell should not be setup");
698        assert!(!parsed.steps[4].is_setup(), "http should not be setup");
699        assert!(
700            !parsed.steps[5].is_setup(),
701            "playwright should not be setup"
702        );
703    }
704}