1use anyhow::{Result, bail};
2
3use crate::machine::Machine;
4
5#[derive(Debug, PartialEq, Eq)]
7pub enum SystemdStatus {
8 Active,
9 Failed,
10 Inactive,
11}
12
13impl SystemdStatus {
14 pub fn parse(s: &str) -> Self {
15 match s {
16 "active" => Self::Active,
17 "failed" => Self::Failed,
18 _ => Self::Inactive,
19 }
20 }
21}
22
23#[allow(dead_code)]
24impl Machine {
25 pub async fn assert_service_active(&self, unit: &str) -> Result<()> {
26 let cmd = format!("systemctl --user is-active {unit}");
27 let output = self.exec(&cmd).await?;
28 let status = SystemdStatus::parse(output.stdout_trimmed());
29 if status != SystemdStatus::Active {
30 bail!(
31 "expected service {unit} to be active, got: {}",
32 output.stdout_trimmed()
33 );
34 }
35 Ok(())
36 }
37
38 pub async fn assert_service_inactive(&self, unit: &str) -> Result<()> {
39 let cmd = format!("systemctl --user is-active {unit} 2>/dev/null || echo inactive");
40 let output = self.exec(&cmd).await?;
41 let status = SystemdStatus::parse(output.stdout_trimmed());
42 if status == SystemdStatus::Active {
43 bail!("expected service {unit} to be inactive, but it is active");
44 }
45 Ok(())
46 }
47
48 pub async fn assert_curl(&self, url: &str, expected_status: u16) -> Result<()> {
49 let cmd = format!("curl -s -o /dev/null -w '%{{http_code}}' {url}");
50 let output = self.exec(&cmd).await?;
51 let code: u16 = output.stdout_trimmed().parse().map_err(|e| {
52 anyhow::anyhow!(
53 "failed to parse HTTP status from curl output '{}': {e}",
54 output.stdout_trimmed()
55 )
56 })?;
57 if code != expected_status {
58 bail!("expected HTTP {expected_status} from {url}, got {code}");
59 }
60 Ok(())
61 }
62
63 pub async fn assert_journal_clean(&self, unit: &str) -> Result<()> {
64 let cmd = format!("journalctl _SYSTEMD_USER_UNIT={unit} -p err -q --no-pager");
65 let output = self.exec(&cmd).await?;
66 let errors = output.stdout_trimmed();
67 if !errors.is_empty() {
68 bail!("found error-level journal entries for {unit}:\n{errors}");
69 }
70 Ok(())
71 }
72
73 pub async fn assert_file_exists(&self, path: &str) -> Result<()> {
74 self.exec(&format!("test -e {path}")).await?;
75 Ok(())
76 }
77
78 pub async fn assert_file_not_exists(&self, path: &str) -> Result<()> {
79 let result = self
80 .exec(&format!("test -e {path} && echo exists || echo missing"))
81 .await?;
82 if result.stdout_trimmed().contains("exists") {
83 bail!("expected {path} to not exist, but it does");
84 }
85 Ok(())
86 }
87
88 pub async fn wait_for_service(&self, unit: &str, timeout: std::time::Duration) -> Result<()> {
89 let start = std::time::Instant::now();
90 loop {
91 let cmd = format!(
92 "s=$(systemctl --user is-active {unit} 2>/dev/null); \
93 if [ \"$s\" = active ] || [ \"$s\" = failed ]; then echo $s; \
94 else echo inactive; fi"
95 );
96 if let Ok(output) = self.exec(&cmd).await {
97 match SystemdStatus::parse(output.stdout_trimmed()) {
98 SystemdStatus::Active => return Ok(()),
99 SystemdStatus::Failed => {
100 let diag_cmd = format!(
101 "systemctl --user status {unit} 2>&1 | head -15; echo '---'; journalctl --user -u {unit} --no-pager -n 10 2>&1"
102 );
103 let diag = self
104 .exec(&diag_cmd)
105 .await
106 .map(|o| o.stdout.trim().to_string())
107 .unwrap_or_default();
108 bail!("service {unit} failed to start:\n{diag}");
109 }
110 SystemdStatus::Inactive => {}
111 }
112 }
113
114 if start.elapsed() > timeout {
115 bail!(
116 "timed out waiting for {unit} to become active after {}s",
117 timeout.as_secs()
118 );
119 }
120
121 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
122 }
123 }
124}