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