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 #[serde(skip)]
152 project_path: Option<std::path::PathBuf>,
153 },
154 Remove {
155 service: String,
156 },
157 Wait {
158 service: String,
159 #[serde(default = "default_timeout")]
160 timeout: u64,
161 },
162 Shell {
164 name: String,
165 run: String,
166 #[serde(default = "default_timeout")]
167 timeout: u64,
168 #[serde(default)]
171 poll: Option<PollConfig>,
172 },
173 Http {
177 #[serde(default)]
178 name: Option<String>,
179 url: String,
180 #[serde(default)]
181 method: HttpMethod,
182 #[serde(default)]
185 body: Option<String>,
186 #[serde(default = "default_content_type")]
189 content_type: String,
190 #[serde(default)]
193 headers: BTreeMap<String, String>,
194 #[serde(default = "default_http_status")]
195 status: u16,
196 #[serde(default)]
199 service: Option<String>,
200 #[serde(default)]
201 poll: Option<PollConfig>,
202 #[serde(default = "default_timeout")]
203 timeout: u64,
204 },
205 Playwright {
207 #[serde(default)]
208 name: Option<String>,
209 spec: String,
210 #[serde(default)]
211 env: BTreeMap<String, String>,
212 #[serde(default = "default_browser_timeout")]
213 timeout: u64,
214 },
215 Mail {
221 #[serde(default)]
222 name: Option<String>,
223 mailbox: String,
225 #[serde(default)]
228 contains: Option<String>,
229 #[serde(default = "default_mail_poll")]
232 poll: PollConfig,
233 #[serde(default = "default_timeout")]
234 timeout: u64,
235 },
236}
237
238fn default_mail_poll() -> PollConfig {
239 PollConfig {
240 interval: 2,
241 attempts: 30,
242 }
243}
244
245fn default_browser_timeout() -> u64 {
246 120
247}
248
249impl std::fmt::Display for StepDef {
250 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251 match self {
252 StepDef::Add { service, .. } => write!(f, "add {service}"),
253 StepDef::Remove { service } => write!(f, "remove {service}"),
254 StepDef::Wait { service, .. } => write!(f, "wait {service}"),
255 StepDef::Shell { name, .. } => write!(f, "shell: {name}"),
256 StepDef::Http { name, url, .. } => {
257 write!(f, "http: {}", name.as_deref().unwrap_or(url))
258 }
259 StepDef::Playwright { name, spec, .. } => {
260 write!(f, "browser: {}", name.as_deref().unwrap_or(spec))
261 }
262 StepDef::Mail { name, mailbox, .. } => {
263 write!(f, "mail: {}", name.as_deref().unwrap_or(mailbox))
264 }
265 }
266 }
267}
268
269impl StepDef {
270 pub fn service(&self) -> Option<&str> {
272 match self {
273 StepDef::Add { service, .. }
274 | StepDef::Remove { service }
275 | StepDef::Wait { service, .. } => Some(service),
276 _ => None,
277 }
278 }
279
280 pub fn is_setup(&self) -> bool {
283 matches!(
284 self,
285 StepDef::Add { .. } | StepDef::Remove { .. } | StepDef::Wait { .. }
286 )
287 }
288
289 pub fn step_name(&self) -> String {
291 format!("{self}")
292 }
293
294 pub fn describe(&self) -> Vec<String> {
298 let mut lines = Vec::new();
299 match self {
300 StepDef::Add {
301 service,
302 args,
303 env,
304 timeout,
305 ..
306 } => {
307 let args_s = args
308 .as_deref()
309 .filter(|s| !s.is_empty())
310 .map(|a| format!(" {a}"))
311 .unwrap_or_default();
312 lines.push(format!("ryra add {service}{args_s} (timeout={timeout}s)"));
313 for (k, v) in env {
314 lines.push(format!(" env {k}={v}"));
315 }
316 }
317 StepDef::Remove { service } => lines.push(format!("ryra remove --purge {service}")),
318 StepDef::Wait { service, timeout } => {
319 lines.push(format!("wait for {service}.service (timeout={timeout}s)"));
320 }
321 StepDef::Shell {
322 name,
323 run,
324 timeout,
325 poll,
326 } => {
327 let poll_s = match poll {
328 Some(p) => {
329 format!(
330 " poll={{interval={}s, attempts={}}}",
331 p.interval, p.attempts
332 )
333 }
334 None => String::new(),
335 };
336 lines.push(format!("shell '{name}' (timeout={timeout}s{poll_s})"));
337 for l in run.trim().lines() {
338 lines.push(format!(" | {l}"));
339 }
340 }
341 StepDef::Http {
342 name,
343 url,
344 method,
345 body,
346 content_type,
347 headers,
348 status,
349 service,
350 poll,
351 timeout,
352 } => {
353 let label = name.as_deref().unwrap_or("(anon)");
354 let verb = method.as_curl_arg();
355 lines.push(format!(
356 "http '{label}': {verb} {url} (expect {status}, timeout={timeout}s)"
357 ));
358 if let Some(svc) = service {
359 lines.push(format!(" env-source: {svc}/.env"));
360 }
361 for (k, v) in headers {
362 lines.push(format!(" header {k}: {v}"));
363 }
364 if let Some(b) = body {
365 lines.push(format!(" content-type: {content_type}"));
366 for l in b.trim().lines() {
367 lines.push(format!(" body> {l}"));
368 }
369 }
370 if let Some(p) = poll {
371 lines.push(format!(
372 " poll: every {}s, up to {} attempts",
373 p.interval, p.attempts
374 ));
375 }
376 }
377 StepDef::Playwright {
378 name,
379 spec,
380 env,
381 timeout,
382 } => {
383 let label = name.as_deref().unwrap_or(spec);
384 lines.push(format!(
385 "playwright '{label}': spec={spec} (timeout={timeout}s)"
386 ));
387 for (k, v) in env {
388 lines.push(format!(" env {k}={v}"));
389 }
390 }
391 StepDef::Mail {
392 name,
393 mailbox,
394 contains,
395 poll,
396 timeout,
397 } => {
398 let label = name.as_deref().unwrap_or(mailbox);
399 lines.push(format!(
400 "mail '{label}': mailbox={mailbox} (timeout={timeout}s)"
401 ));
402 if let Some(c) = contains {
403 lines.push(format!(" contains: {c}"));
404 }
405 lines.push(format!(
406 " poll: every {}s, up to {} attempts",
407 poll.interval, poll.attempts
408 ));
409 }
410 }
411 lines
412 }
413}
414
415impl TestToml {
416 pub fn parse(path: &Path) -> Result<Self> {
418 let content = std::fs::read_to_string(path)
419 .with_context(|| format!("failed to read test.toml at {}", path.display()))?;
420 let parsed: Self = toml::from_str(&content)
421 .with_context(|| format!("failed to parse test.toml at {}", path.display()))?;
422 parsed.validate(path)?;
423 Ok(parsed)
424 }
425
426 pub fn validate(&self, path: &Path) -> Result<()> {
432 let ctx = path.display();
433
434 let has_legacy_run_tests = self
439 .tests
440 .iter()
441 .any(|t| t.run.is_some() && t.steps.is_empty());
442 if has_legacy_run_tests && !self.steps.is_empty() {
443 anyhow::bail!(
444 "{ctx}: test.toml cannot mix [setup]+[[tests]] (legacy shell) with top-level [[steps]] — \
445 migrate to the new [[tests]] + [[tests.steps]] format instead",
446 );
447 }
448
449 for t in &self.tests {
450 let has_run = t.run.is_some();
451 let has_steps = !t.steps.is_empty();
452 if has_run == has_steps {
453 anyhow::bail!(
454 "{ctx}: test '{}' must set exactly one of `run` or `steps` \
455 (got run={}, steps={})",
456 t.name,
457 has_run,
458 has_steps,
459 );
460 }
461 }
462
463 Ok(())
464 }
465
466 pub fn is_lifecycle(&self) -> bool {
468 !self.steps.is_empty()
469 }
470
471 pub fn needs_browser(&self) -> bool {
473 self.test.as_ref().is_some_and(|t| t.browser)
474 }
475
476 pub fn ram_override(&self) -> Option<u32> {
478 self.test.as_ref().and_then(|t| t.ram)
479 }
480
481 pub fn requires_sudo(&self) -> bool {
483 self.test.as_ref().is_some_and(|t| t.requires_sudo)
484 }
485
486 pub fn name_or_default(&self, path: &Path) -> String {
488 if let Some(ref meta) = self.test
489 && let Some(ref name) = meta.name
490 {
491 return name.clone();
492 }
493 path.file_stem()
494 .and_then(|s| s.to_str())
495 .unwrap_or("unknown")
496 .to_string()
497 }
498
499 pub fn referenced_services(&self) -> Vec<String> {
501 let mut services: Vec<String> = self
502 .setup
503 .as_ref()
504 .map_or_else(Vec::new, |s| s.services.clone());
505
506 for step in &self.steps {
507 if let StepDef::Add { service, .. } = step
508 && !services.contains(service)
509 {
510 services.push(service.clone());
511 }
512 }
513
514 services
515 }
516
517 pub fn quadlet_files(&self) -> Vec<String> {
519 self.setup
520 .as_ref()
521 .map_or_else(Vec::new, |s| s.quadlets.clone())
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use std::io::Write as _;
529
530 fn write_temp(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
531 let dir = tempfile::tempdir().expect("tempdir");
532 let path = dir.path().join("test.toml");
533 let mut f = std::fs::File::create(&path).expect("create");
534 f.write_all(content.as_bytes()).expect("write");
535 (dir, path)
536 }
537
538 #[test]
539 fn reject_mixed_tests_and_steps() {
540 let toml = r#"
541[[tests]]
542name = "foo"
543run = "true"
544
545[[steps]]
546action = "add"
547service = "bar"
548"#;
549 let (_dir, path) = write_temp(toml);
550 let result = TestToml::parse(&path);
551 assert!(result.is_err(), "expected error for mixed tests+steps");
552 let msg = format!("{:#}", result.unwrap_err());
553 assert!(msg.contains("[[tests]]") || msg.contains("[[steps]]"));
554 }
555
556 #[test]
557 fn name_from_metadata() {
558 let toml = r#"
559[test]
560name = "my explicit name"
561
562[[tests]]
563name = "check"
564run = "true"
565"#;
566 let (_dir, path) = write_temp(toml);
567 let parsed = TestToml::parse(&path).expect("parse");
568 assert_eq!(parsed.name_or_default(&path), "my explicit name");
569 }
570
571 #[test]
572 fn name_from_filename() {
573 let toml = r#"
574[[tests]]
575name = "check"
576run = "true"
577"#;
578 let dir = tempfile::tempdir().expect("tempdir");
579 let path = dir.path().join("immich-sso.toml");
580 std::fs::write(&path, toml).expect("write");
581 let parsed = TestToml::parse(&path).expect("parse");
582 assert_eq!(parsed.name_or_default(&path), "immich-sso");
583 }
584
585 #[test]
586 fn browser_step_requires_spec() {
587 let toml = r#"
588[[steps]]
589action = "playwright"
590"#;
591 let (_dir, path) = write_temp(toml);
592 let result = TestToml::parse(&path);
593 assert!(result.is_err());
594 let msg = format!("{:#}", result.unwrap_err());
595 assert!(msg.contains("spec") || msg.contains("missing field"));
596 }
597
598 #[test]
599 fn run_step_rejects_missing_name() {
600 let toml = r#"
601[[steps]]
602action = "shell"
603run = "true"
604"#;
605 let (_dir, path) = write_temp(toml);
606 let result = TestToml::parse(&path);
607 assert!(result.is_err(), "run step without 'name' should fail");
608 }
609
610 #[test]
611 fn add_step_default_timeout() {
612 let toml = r#"
613[[steps]]
614action = "add"
615service = "whoami"
616"#;
617 let (_dir, path) = write_temp(toml);
618 let parsed = TestToml::parse(&path).expect("parse");
619 if let StepDef::Add { timeout, .. } = parsed.steps[0] {
620 assert_eq!(timeout, 300);
621 } else {
622 panic!("expected Add step");
623 }
624 }
625
626 #[test]
627 fn http_step_defaults() {
628 let toml = r#"
629[[steps]]
630action = "http"
631url = "http://localhost:8080"
632"#;
633 let (_dir, path) = write_temp(toml);
634 let parsed = TestToml::parse(&path).expect("parse");
635 if let StepDef::Http {
636 status, timeout, ..
637 } = parsed.steps[0]
638 {
639 assert_eq!(status, 200);
640 assert_eq!(timeout, 30);
641 } else {
642 panic!("expected Http step");
643 }
644 }
645
646 #[test]
647 fn mail_step_defaults() {
648 let toml = r#"
649[[steps]]
650action = "mail"
651mailbox = "smtptest"
652"#;
653 let (_dir, path) = write_temp(toml);
654 let parsed = TestToml::parse(&path).expect("parse");
655 if let StepDef::Mail {
656 ref contains,
657 ref poll,
658 timeout,
659 ..
660 } = parsed.steps[0]
661 {
662 assert!(contains.is_none(), "contains defaults to None");
663 assert_eq!(poll.interval, 2, "default poll interval");
664 assert_eq!(poll.attempts, 30, "default poll attempts");
665 assert_eq!(timeout, 30);
666 } else {
667 panic!("expected Mail step");
668 }
669 }
670
671 #[test]
672 fn is_setup_classification() {
673 let toml = r#"
674[[steps]]
675action = "add"
676service = "whoami"
677
678[[steps]]
679action = "remove"
680service = "whoami"
681
682[[steps]]
683action = "wait"
684service = "whoami"
685
686[[steps]]
687action = "shell"
688name = "check"
689run = "true"
690
691[[steps]]
692action = "http"
693url = "http://localhost:8080"
694
695[[steps]]
696action = "playwright"
697spec = "test.spec.ts"
698"#;
699 let (_dir, path) = write_temp(toml);
700 let parsed = TestToml::parse(&path).expect("parse");
701 assert!(parsed.steps[0].is_setup(), "add should be setup");
702 assert!(parsed.steps[1].is_setup(), "remove should be setup");
703 assert!(parsed.steps[2].is_setup(), "wait should be setup");
704 assert!(!parsed.steps[3].is_setup(), "shell should not be setup");
705 assert!(!parsed.steps[4].is_setup(), "http should not be setup");
706 assert!(
707 !parsed.steps[5].is_setup(),
708 "playwright should not be setup"
709 );
710 }
711}