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