1use std::collections::BTreeMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6
7#[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 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#[derive(Debug, Clone, Deserialize)]
51pub struct TestDef {
52 pub name: String,
53 #[serde(default)]
55 pub run: Option<String>,
56 #[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 #[serde(default)]
66 pub browser: bool,
67 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#[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 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#[derive(Debug, Clone, Deserialize)]
114pub struct PollConfig {
115 pub interval: u64,
117 pub attempts: u64,
119}
120
121#[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 {
146 name: String,
147 run: String,
148 #[serde(default = "default_timeout")]
149 timeout: u64,
150 #[serde(default)]
153 poll: Option<PollConfig>,
154 },
155 Http {
159 #[serde(default)]
160 name: Option<String>,
161 url: String,
162 #[serde(default)]
163 method: HttpMethod,
164 #[serde(default)]
167 body: Option<String>,
168 #[serde(default = "default_content_type")]
171 content_type: String,
172 #[serde(default)]
175 headers: BTreeMap<String, String>,
176 #[serde(default = "default_http_status")]
177 status: u16,
178 #[serde(default)]
181 service: Option<String>,
182 #[serde(default)]
183 poll: Option<PollConfig>,
184 #[serde(default = "default_timeout")]
185 timeout: u64,
186 },
187 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 Mail {
203 #[serde(default)]
204 name: Option<String>,
205 mailbox: String,
207 #[serde(default)]
210 contains: Option<String>,
211 #[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 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 pub fn is_setup(&self) -> bool {
265 matches!(
266 self,
267 StepDef::Add { .. } | StepDef::Remove { .. } | StepDef::Wait { .. }
268 )
269 }
270
271 pub fn step_name(&self) -> String {
273 format!("{self}")
274 }
275
276 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 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 pub fn validate(&self, path: &Path) -> Result<()> {
413 let ctx = path.display();
414
415 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 pub fn is_lifecycle(&self) -> bool {
449 !self.steps.is_empty()
450 }
451
452 pub fn needs_browser(&self) -> bool {
454 self.test.as_ref().is_some_and(|t| t.browser)
455 }
456
457 pub fn ram_override(&self) -> Option<u32> {
459 self.test.as_ref().and_then(|t| t.ram)
460 }
461
462 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 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 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}