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 Reset,
140 Wait {
141 service: String,
142 #[serde(default = "default_timeout")]
143 timeout: u64,
144 },
145 Shell {
147 name: String,
148 run: String,
149 #[serde(default = "default_timeout")]
150 timeout: u64,
151 #[serde(default)]
154 poll: Option<PollConfig>,
155 },
156 Http {
160 #[serde(default)]
161 name: Option<String>,
162 url: String,
163 #[serde(default)]
164 method: HttpMethod,
165 #[serde(default)]
168 body: Option<String>,
169 #[serde(default = "default_content_type")]
172 content_type: String,
173 #[serde(default)]
176 headers: BTreeMap<String, String>,
177 #[serde(default = "default_http_status")]
178 status: u16,
179 #[serde(default)]
182 service: Option<String>,
183 #[serde(default)]
184 poll: Option<PollConfig>,
185 #[serde(default = "default_timeout")]
186 timeout: u64,
187 },
188 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 Mail {
204 #[serde(default)]
205 name: Option<String>,
206 mailbox: String,
208 #[serde(default)]
211 contains: Option<String>,
212 #[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 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 pub fn is_setup(&self) -> bool {
267 matches!(
268 self,
269 StepDef::Add { .. } | StepDef::Remove { .. } | StepDef::Reset | StepDef::Wait { .. }
270 )
271 }
272
273 pub fn step_name(&self) -> String {
275 format!("{self}")
276 }
277
278 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 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 pub fn validate(&self, path: &Path) -> Result<()> {
416 let ctx = path.display();
417
418 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 pub fn is_lifecycle(&self) -> bool {
452 !self.steps.is_empty()
453 }
454
455 pub fn needs_browser(&self) -> bool {
457 self.test.as_ref().is_some_and(|t| t.browser)
458 }
459
460 pub fn ram_override(&self) -> Option<u32> {
462 self.test.as_ref().and_then(|t| t.ram)
463 }
464
465 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 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 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}