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 #[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#[derive(Debug, Clone, Deserialize)]
59pub struct TestDef {
60 pub name: String,
61 #[serde(default)]
63 pub run: Option<String>,
64 #[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 #[serde(default)]
74 pub browser: bool,
75 pub ram: Option<u32>,
77 #[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#[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 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#[derive(Debug, Clone, Deserialize)]
126pub struct PollConfig {
127 pub interval: u64,
129 pub attempts: u64,
131}
132
133#[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 {
158 name: String,
159 run: String,
160 #[serde(default = "default_timeout")]
161 timeout: u64,
162 #[serde(default)]
165 poll: Option<PollConfig>,
166 },
167 Http {
171 #[serde(default)]
172 name: Option<String>,
173 url: String,
174 #[serde(default)]
175 method: HttpMethod,
176 #[serde(default)]
179 body: Option<String>,
180 #[serde(default = "default_content_type")]
183 content_type: String,
184 #[serde(default)]
187 headers: BTreeMap<String, String>,
188 #[serde(default = "default_http_status")]
189 status: u16,
190 #[serde(default)]
193 service: Option<String>,
194 #[serde(default)]
195 poll: Option<PollConfig>,
196 #[serde(default = "default_timeout")]
197 timeout: u64,
198 },
199 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 Mail {
215 #[serde(default)]
216 name: Option<String>,
217 mailbox: String,
219 #[serde(default)]
222 contains: Option<String>,
223 #[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 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 pub fn is_setup(&self) -> bool {
277 matches!(
278 self,
279 StepDef::Add { .. } | StepDef::Remove { .. } | StepDef::Wait { .. }
280 )
281 }
282
283 pub fn step_name(&self) -> String {
285 format!("{self}")
286 }
287
288 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 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 pub fn validate(&self, path: &Path) -> Result<()> {
425 let ctx = path.display();
426
427 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 pub fn is_lifecycle(&self) -> bool {
461 !self.steps.is_empty()
462 }
463
464 pub fn needs_browser(&self) -> bool {
466 self.test.as_ref().is_some_and(|t| t.browser)
467 }
468
469 pub fn ram_override(&self) -> Option<u32> {
471 self.test.as_ref().and_then(|t| t.ram)
472 }
473
474 pub fn requires_sudo(&self) -> bool {
476 self.test.as_ref().is_some_and(|t| t.requires_sudo)
477 }
478
479 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 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 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}