1pub mod args;
6
7use anyhow::{Result, bail, format_err};
8use args::{HealthCheckCommand, HealthCheckLevelArg};
9use flex_fuchsia_driver_development as fdd;
10use flex_fuchsia_driver_framework as fdf;
11#[cfg(not(feature = "fdomain"))]
12use fuchsia_driver_dev::get_device_info;
13#[cfg(feature = "fdomain")]
14use fuchsia_driver_dev_fdomain::get_device_info;
15use futures::StreamExt;
16use std::io::Write;
17
18const MAX_CONCURRENT_CHECKS: usize = 8;
20
21impl From<HealthCheckLevelArg> for fdf::HealthCheckRequest {
22 fn from(level: HealthCheckLevelArg) -> Self {
23 match level {
24 HealthCheckLevelArg::Ping => fdf::HealthCheckRequest::Ping,
25 HealthCheckLevelArg::Fast => fdf::HealthCheckRequest::Fast,
26 HealthCheckLevelArg::Deep => fdf::HealthCheckRequest::Deep,
27 }
28 }
29}
30
31#[derive(Clone, Copy, Debug, PartialEq)]
33enum Outcome {
34 Ok,
35 Degraded,
36 Unhealthy,
37 Error,
40 NoInfo,
44}
45
46fn derive_health_status(resp: &fdf::HealthCheckResponse) -> fdf::HealthStatus {
47 let mut overall = fdf::HealthStatus::Ok;
48 if let Some(conditions) = &resp.conditions {
49 for condition in conditions {
50 let status = condition.status.unwrap_or(fdf::HealthStatus::Unhealthy);
51 if status == fdf::HealthStatus::Degraded {
52 overall = fdf::HealthStatus::Degraded;
53 } else if status != fdf::HealthStatus::Ok {
54 return fdf::HealthStatus::Unhealthy;
55 }
56 }
57 }
58 overall
59}
60
61fn classify(res: &Result<fdf::HealthCheckResponse, fdf::HealthCheckError>) -> Outcome {
62 match res {
63 Ok(resp) => match derive_health_status(resp) {
64 fdf::HealthStatus::Ok => Outcome::Ok,
65 fdf::HealthStatus::Degraded => Outcome::Degraded,
66 fdf::HealthStatus::Unhealthy => Outcome::Unhealthy,
67 _ => Outcome::NoInfo,
68 },
69 Err(fdf::HealthCheckError::Timeout | fdf::HealthCheckError::InternalError) => {
70 Outcome::Error
71 }
72 Err(_) => Outcome::NoInfo,
73 }
74}
75
76#[derive(Default)]
77struct Summary {
78 ok: usize,
79 degraded: usize,
80 unhealthy: usize,
81 errors: usize,
82 no_info: usize,
83}
84
85impl Summary {
86 fn record(&mut self, outcome: Outcome) {
87 match outcome {
88 Outcome::Ok => self.ok += 1,
89 Outcome::Degraded => self.degraded += 1,
90 Outcome::Unhealthy => self.unhealthy += 1,
91 Outcome::Error => self.errors += 1,
92 Outcome::NoInfo => self.no_info += 1,
93 }
94 }
95
96 fn checked(&self) -> usize {
97 self.ok + self.degraded + self.unhealthy + self.errors + self.no_info
98 }
99
100 fn problems(&self) -> usize {
102 self.degraded + self.unhealthy + self.errors
103 }
104}
105
106fn health_status_str(status: Option<fdf::HealthStatus>) -> &'static str {
107 match status {
108 Some(fdf::HealthStatus::Ok) => "OK",
109 Some(fdf::HealthStatus::Degraded) => "DEGRADED",
110 Some(fdf::HealthStatus::Unhealthy) => "UNHEALTHY",
111 _ => "UNKNOWN",
112 }
113}
114
115fn diagnostic_value_str(value: &fdf::HealthDiagnosticValue) -> String {
116 match value {
117 fdf::HealthDiagnosticValue::StringValue(s) => s.clone(),
118 fdf::HealthDiagnosticValue::IntValue(i) => i.to_string(),
119 fdf::HealthDiagnosticValue::UintValue(u) => format!("0x{:x} ({})", u, u),
120 fdf::HealthDiagnosticValue::BoolValue(b) => b.to_string(),
121 fdf::HealthDiagnosticValue::Timestamp(t) => format!("{} (boot ns)", t.into_nanos()),
122 _ => String::from("<unknown>"),
123 }
124}
125
126fn format_age(age_ns: i64) -> String {
128 const NS_PER_MS: i64 = 1_000_000;
129 const NS_PER_S: i64 = 1_000 * NS_PER_MS;
130 const NS_PER_MIN: i64 = 60 * NS_PER_S;
131 const NS_PER_HOUR: i64 = 60 * NS_PER_MIN;
132 if age_ns < 0 {
133 return String::from("in the future");
134 }
135 if age_ns < NS_PER_S {
136 format!("{}ms", age_ns / NS_PER_MS)
137 } else if age_ns < NS_PER_MIN {
138 format!("{:.1}s", age_ns as f64 / NS_PER_S as f64)
139 } else if age_ns < NS_PER_HOUR {
140 format!("{}m{}s", age_ns / NS_PER_MIN, (age_ns % NS_PER_MIN) / NS_PER_S)
141 } else {
142 format!("{}h{}m", age_ns / NS_PER_HOUR, (age_ns % NS_PER_HOUR) / NS_PER_MIN)
143 }
144}
145
146fn write_diagnostics(
147 writer: &mut dyn Write,
148 indent: &str,
149 diagnostics: &[fdf::HealthDiagnosticItem],
150) -> Result<()> {
151 for item in diagnostics {
152 writeln!(writer, "{}{} = {}", indent, item.key, diagnostic_value_str(&item.value))?;
153 }
154 Ok(())
155}
156
157fn print_response(
161 writer: &mut dyn Write,
162 target: &str,
163 res: &Result<fdf::HealthCheckResponse, fdf::HealthCheckError>,
164 now_boot_ns: Option<i64>,
165) -> Result<()> {
166 match res {
167 Ok(resp) => {
168 let status = derive_health_status(resp);
169 writeln!(writer, "[{}] Status: {}", target, health_status_str(Some(status)))?;
170 if let Some(conditions) = &resp.conditions {
171 if !conditions.is_empty() {
172 writeln!(writer, " Conditions:")?;
173 for condition in conditions {
174 let mut details = Vec::new();
175 if let Some(last_observed) = condition.last_observed.map(|t| t.into_nanos())
176 {
177 match now_boot_ns {
178 Some(now) => details.push(format!(
179 "last observed {} ago",
180 format_age(now - last_observed)
181 )),
182 None => details
183 .push(format!("last observed at {} boot ns", last_observed)),
184 }
185 }
186 if let Some(count) = condition.occurrence_count {
187 details.push(format!("{}x", count));
188 }
189 let suffix = if details.is_empty() {
190 String::new()
191 } else {
192 format!(" ({})", details.join(", "))
193 };
194 writeln!(
195 writer,
196 " [{}] {}{}",
197 health_status_str(condition.status),
198 condition.reason_code.as_deref().unwrap_or("-"),
199 suffix
200 )?;
201 if let Some(message) = &condition.message {
202 if !message.is_empty() {
203 writeln!(writer, " {}", message)?;
204 }
205 }
206 if let Some(diagnostics) = &condition.diagnostics {
207 write_diagnostics(writer, " ", diagnostics)?;
208 }
209 }
210 }
211 }
212 if let Some(diagnostics) = &resp.diagnostics {
213 if !diagnostics.is_empty() {
214 writeln!(writer, " Diagnostics:")?;
215 write_diagnostics(writer, " ", diagnostics)?;
216 }
217 }
218 }
219 Err(err) => {
220 writeln!(writer, "[{}] ERROR: {:?}", target, err)?;
221 }
222 }
223 Ok(())
224}
225
226fn current_boot_ns() -> Option<i64> {
229 #[cfg(target_os = "fuchsia")]
230 {
231 Some(zx::BootInstant::get().into_nanos())
232 }
233 #[cfg(not(target_os = "fuchsia"))]
234 {
235 None
236 }
237}
238
239pub async fn health_check(
240 cmd: HealthCheckCommand,
241 writer: &mut dyn Write,
242 driver_development_proxy: fdd::ManagerProxy,
243) -> Result<()> {
244 health_check_with_now(cmd, writer, driver_development_proxy, current_boot_ns).await
245}
246
247async fn health_check_with_now(
258 cmd: HealthCheckCommand,
259 writer: &mut dyn Write,
260 driver_development_proxy: fdd::ManagerProxy,
261 now_boot_ns: impl Fn() -> Option<i64>,
262) -> Result<()> {
263 let request: fdf::HealthCheckRequest = cmd.level.into();
264
265 let (monikers, is_all) = match (cmd.moniker, cmd.all) {
266 (Some(_), true) => {
267 bail!("`--all` cannot be combined with a moniker. Please specify one or the other.");
268 }
269 (Some(moniker), false) => (vec![moniker], false),
270 (None, true) => {
271 let nodes = get_device_info(&driver_development_proxy, &[], false).await?;
272 let monikers = nodes
273 .into_iter()
274 .filter(|node| {
275 if node.quarantined == Some(true) {
281 return false;
282 }
283 !matches!(
284 node.bound_driver_url.as_deref(),
285 None | Some("" | "unbound" | "owned by parent" | "owned by composite(s)")
286 )
287 })
288 .filter_map(|node| node.moniker)
289 .collect();
290 (monikers, true)
291 }
292 (None, false) => {
293 bail!(
294 "Please specify the exact component moniker or topological path of a node, or pass `--all` to check all bound drivers."
295 );
296 }
297 };
298
299 let mut results = futures::stream::iter(monikers)
302 .map(|moniker| {
303 let proxy = &driver_development_proxy;
304 let now = &now_boot_ns;
305 async move {
306 let res = proxy.run_health_check(&moniker, request).await;
307 (moniker, res, now())
308 }
309 })
310 .buffered(MAX_CONCURRENT_CHECKS);
311
312 let mut summary = Summary::default();
313 while let Some((moniker, res, checked_at_boot_ns)) = results.next().await {
314 match res {
315 Ok(res) => {
316 print_response(writer, &moniker, &res, checked_at_boot_ns)?;
317 let outcome = classify(&res);
318 if !is_all {
319 return match outcome {
320 Outcome::Ok => Ok(()),
321 _ => Err(format_err!("The health check of {} did not return OK.", moniker)),
322 };
323 }
324 summary.record(outcome);
325 }
326 Err(e) => {
327 if !is_all {
328 return Err(e.into());
329 }
330 writeln!(writer, "[{}] ERROR: {}", moniker, e)?;
331 summary.record(Outcome::Error);
332 }
333 }
334 }
335
336 writeln!(
337 writer,
338 "{} checked: ok {}, degraded {}, unhealthy {}, errors {}, no-info {}",
339 summary.checked(),
340 summary.ok,
341 summary.degraded,
342 summary.unhealthy,
343 summary.errors,
344 summary.no_info
345 )?;
346 if summary.problems() > 0 {
347 bail!("{} of {} drivers reported a problem.", summary.problems(), summary.checked());
348 }
349 Ok(())
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use anyhow::Context;
356 use argh::FromArgs;
357 use flex_client::fidl::ServerEnd;
358 use fuchsia_async as fasync;
359 use futures::future::{Future, FutureExt};
360 use std::sync::Arc;
361 use std::sync::atomic::{AtomicI64, Ordering};
362
363 async fn run_health_check_test<F, Fut>(
366 #[cfg(feature = "fdomain")] client: Arc<flex_client::Client>,
367 cmd: HealthCheckCommand,
368 on_driver_development_request: F,
369 ) -> (Result<()>, String)
370 where
371 F: Fn(fdd::ManagerRequest) -> Fut + Send + Sync + 'static,
372 Fut: Future<Output = Result<()>> + Send + Sync,
373 {
374 #[cfg(not(feature = "fdomain"))]
375 let client = flex_client::fidl::ZirconClient;
376 let (driver_development_proxy, mut driver_development_requests) =
377 client.create_proxy_and_stream::<fdd::ManagerMarker>();
378
379 let mut writer = Vec::new();
380 let clock_boot_ns = Arc::new(AtomicI64::new(5_000_000_000));
381 let request_clock = Arc::clone(&clock_boot_ns);
382 let request_handler_task = fasync::Task::spawn(async move {
383 while let Some(res) = driver_development_requests.next().await {
384 let request = res.context("Failed to get next request")?;
385 if matches!(request, fdd::ManagerRequest::RunHealthCheck { .. }) {
389 request_clock.store(10_000_000_000, Ordering::SeqCst);
390 }
391 on_driver_development_request(request).await.context("Failed to handle request")?;
392 }
393 anyhow::bail!("Driver development request stream unexpectedly closed");
394 });
395 let result = futures::select! {
396 res = request_handler_task.fuse() => {
397 res.and_then(|()| Err::<(), _>(format_err!("Request handler task unexpectedly finished")))
398 }
399 res = health_check_with_now(
400 cmd,
401 &mut writer,
402 driver_development_proxy,
403 || Some(clock_boot_ns.load(Ordering::SeqCst)),
404 ).fuse() => {
405 res
406 }
407 };
408
409 let output = String::from_utf8(writer).expect("health_check output is valid UTF-8");
410 (result, output)
411 }
412
413 #[fuchsia::test]
414 fn test_format_age() {
415 assert_eq!(format_age(-1), "in the future");
416 assert_eq!(format_age(250_000_000), "250ms");
417 assert_eq!(format_age(1_500_000_000), "1.5s");
418 assert_eq!(format_age(90_000_000_000), "1m30s");
419 assert_eq!(format_age(3_660_000_000_000), "1h1m");
420 }
421
422 #[fuchsia::test]
423 async fn test_health_check_single_driver_and_missing_target() {
424 #[cfg(feature = "fdomain")]
425 let client = fdomain_local::local_client_empty();
426
427 let empty_cmd = HealthCheckCommand::from_args(&["health-check"], &[]).unwrap();
429 let (result, _) = run_health_check_test(
430 #[cfg(feature = "fdomain")]
431 Arc::clone(&client),
432 empty_cmd,
433 |_request: fdd::ManagerRequest| async move { Ok(()) },
434 )
435 .await;
436 let err = result.expect_err("a missing moniker without --all must fail");
437 assert!(err.to_string().contains("--all"), "unexpected error: {err}");
438
439 let both_cmd =
441 HealthCheckCommand::from_args(&["health-check"], &["vivante-display", "--all"])
442 .unwrap();
443 let (result, _) = run_health_check_test(
444 #[cfg(feature = "fdomain")]
445 Arc::clone(&client),
446 both_cmd,
447 |_request: fdd::ManagerRequest| async move { anyhow::bail!("Unexpected request") },
448 )
449 .await;
450 let err = result.expect_err("a moniker combined with --all must fail");
451 assert!(err.to_string().contains("cannot be combined"), "unexpected error: {err}");
452
453 let cmd = HealthCheckCommand::from_args(
456 &["health-check"],
457 &["vivante-display", "--level", "deep"],
458 )
459 .unwrap();
460 let (result, output) = run_health_check_test(
461 #[cfg(feature = "fdomain")]
462 Arc::clone(&client),
463 cmd,
464 |request: fdd::ManagerRequest| async move {
465 match request {
466 fdd::ManagerRequest::RunHealthCheck { moniker, request, responder } => {
467 assert_eq!(moniker, "vivante-display");
468 assert_eq!(request, fdf::HealthCheckRequest::Deep);
469 let resp = fdf::HealthCheckResponse {
470 conditions: Some(vec![
471 fdf::HealthCondition {
472 status: Some(fdf::HealthStatus::Degraded),
473 reason_code: Some(String::from("FIFO_UNDERRUN")),
474 message: Some(String::from("Recovered from underrun")),
475 diagnostics: Some(vec![fdf::HealthDiagnosticItem {
476 key: String::from("underruns"),
477 value: fdf::HealthDiagnosticValue::UintValue(2),
478 }]),
479 last_observed: Some(fidl::BootInstant::from_nanos(
480 8_500_000_000,
481 )),
482 occurrence_count: Some(2),
483 ..Default::default()
484 },
485 fdf::HealthCondition {
486 status: Some(fdf::HealthStatus::Ok),
487 reason_code: Some(String::from("TE_JITTER")),
488 ..Default::default()
489 },
490 ]),
491 diagnostics: Some(vec![
492 fdf::HealthDiagnosticItem {
493 key: String::from("pipe_state"),
494 value: fdf::HealthDiagnosticValue::StringValue(String::from(
495 "active",
496 )),
497 },
498 fdf::HealthDiagnosticItem {
499 key: String::from("drift_ms"),
500 value: fdf::HealthDiagnosticValue::IntValue(-4),
501 },
502 fdf::HealthDiagnosticItem {
503 key: String::from("status_reg"),
504 value: fdf::HealthDiagnosticValue::UintValue(0x1a),
505 },
506 fdf::HealthDiagnosticItem {
507 key: String::from("phy_locked"),
508 value: fdf::HealthDiagnosticValue::BoolValue(true),
509 },
510 fdf::HealthDiagnosticItem {
511 key: String::from("last_vsync"),
512 value: fdf::HealthDiagnosticValue::Timestamp(
513 fidl::BootInstant::from_nanos(9_000_000_000),
514 ),
515 },
516 ]),
517 ..Default::default()
518 };
519 responder.send(Ok(&resp)).context("Failed to send response")?;
520 }
521 _ => anyhow::bail!("Unexpected request"),
522 }
523 Ok(())
524 },
525 )
526 .await;
527
528 assert!(result.is_err(), "a DEGRADED status must fail the command");
529 assert_eq!(
530 output,
531 concat!(
532 "[vivante-display] Status: DEGRADED\n",
533 " Conditions:\n",
534 " [DEGRADED] FIFO_UNDERRUN (last observed 1.5s ago, 2x)\n",
535 " Recovered from underrun\n",
536 " underruns = 0x2 (2)\n",
537 " [OK] TE_JITTER\n",
538 " Diagnostics:\n",
539 " pipe_state = active\n",
540 " drift_ms = -4\n",
541 " status_reg = 0x1a (26)\n",
542 " phy_locked = true\n",
543 " last_vsync = 9000000000 (boot ns)\n",
544 )
545 );
546
547 let cmd = HealthCheckCommand::from_args(&["health-check"], &["vivante-display"]).unwrap();
549 let (result, output) = run_health_check_test(
550 #[cfg(feature = "fdomain")]
551 Arc::clone(&client),
552 cmd,
553 |request: fdd::ManagerRequest| async move {
554 match request {
555 fdd::ManagerRequest::RunHealthCheck { request, responder, .. } => {
556 assert_eq!(request, fdf::HealthCheckRequest::Fast);
558 let resp = fdf::HealthCheckResponse::default();
559 responder.send(Ok(&resp)).context("Failed to send response")?;
560 }
561 _ => anyhow::bail!("Unexpected request"),
562 }
563 Ok(())
564 },
565 )
566 .await;
567 result.unwrap();
568 assert_eq!(output, "[vivante-display] Status: OK\n");
569
570 let cmd = HealthCheckCommand::from_args(&["health-check"], &["vivante-display"]).unwrap();
572 let (result, output) = run_health_check_test(
573 #[cfg(feature = "fdomain")]
574 Arc::clone(&client),
575 cmd,
576 |request: fdd::ManagerRequest| async move {
577 match request {
578 fdd::ManagerRequest::RunHealthCheck { responder, .. } => {
579 responder
580 .send(Err(fdf::HealthCheckError::Timeout))
581 .context("Failed to send response")?;
582 }
583 _ => anyhow::bail!("Unexpected request"),
584 }
585 Ok(())
586 },
587 )
588 .await;
589 assert!(result.is_err(), "a TIMEOUT error must fail the command");
590 assert_eq!(output, "[vivante-display] ERROR: Timeout\n");
591
592 let cmd = HealthCheckCommand::from_args(&["health-check"], &["nonexistent"]).unwrap();
594 let (result, output) = run_health_check_test(
595 #[cfg(feature = "fdomain")]
596 Arc::clone(&client),
597 cmd,
598 |request: fdd::ManagerRequest| async move {
599 match request {
600 fdd::ManagerRequest::RunHealthCheck { responder, .. } => {
601 responder
602 .send(Err(fdf::HealthCheckError::NotFound))
603 .context("Failed to send response")?;
604 }
605 _ => anyhow::bail!("Unexpected request"),
606 }
607 Ok(())
608 },
609 )
610 .await;
611 assert!(result.is_err(), "a NOT_FOUND error must fail the command");
612 assert_eq!(output, "[nonexistent] ERROR: NotFound\n");
613 }
614
615 fn node_info(id: u64, moniker: &str, bound_driver_url: Option<&str>) -> fdd::NodeInfo {
616 fdd::NodeInfo {
617 id: Some(id),
618 moniker: Some(String::from(moniker)),
619 bound_driver_url: bound_driver_url.map(String::from),
620 ..Default::default()
621 }
622 }
623
624 async fn handle_all_request<R>(
627 request: fdd::ManagerRequest,
628 nodes: Vec<fdd::NodeInfo>,
629 respond: R,
630 ) -> Result<()>
631 where
632 R: Fn(&str) -> Result<fdf::HealthCheckResponse, fdf::HealthCheckError>,
633 {
634 match request {
635 fdd::ManagerRequest::GetNodeInfo { iterator, .. } => {
636 let mut device_infos = nodes;
637 let mut stream =
638 ServerEnd::<fdd::NodeInfoIteratorMarker>::from(iterator).into_stream();
639 while let Some(Ok(fdd::NodeInfoIteratorRequest::GetNext { responder })) =
640 stream.next().await
641 {
642 responder.send(&device_infos)?;
643 device_infos.clear();
644 }
645 }
646 fdd::ManagerRequest::RunHealthCheck { moniker, request, responder } => {
647 assert_eq!(request, fdf::HealthCheckRequest::Ping);
648 responder.send(respond(&moniker).as_ref().map_err(|e| *e))?;
649 }
650 _ => anyhow::bail!("Unexpected request"),
651 }
652 Ok(())
653 }
654
655 #[fuchsia::test]
656 async fn test_health_check_all_bound_drivers() {
657 #[cfg(feature = "fdomain")]
658 let client = fdomain_local::local_client_empty();
659 let cmd =
660 HealthCheckCommand::from_args(&["health-check"], &["--all", "-l", "ping"]).unwrap();
661
662 let (result, output) = run_health_check_test(
665 #[cfg(feature = "fdomain")]
666 Arc::clone(&client),
667 cmd,
668 |request: fdd::ManagerRequest| async move {
669 let mut quarantined_node =
670 node_info(5, "dev.quarantined", Some("fuchsia-boot:///#meta/broken.cm"));
671 quarantined_node.quarantined = Some(true);
672 let nodes = vec![
673 node_info(1, "dev.none", None),
674 node_info(2, "dev.unbound", Some("unbound")),
675 node_info(3, "dev.owned", Some("owned by parent")),
676 node_info(4, "dev.composite_parent", Some("owned by composite(s)")),
677 quarantined_node,
678 node_info(6, "dev.display", Some("fuchsia-boot:///#meta/display.cm")),
679 ];
680 handle_all_request(request, nodes, |moniker| {
681 assert_eq!(moniker, "dev.display");
682 Ok(fdf::HealthCheckResponse::default())
683 })
684 .await
685 },
686 )
687 .await;
688 result.unwrap();
689 assert_eq!(
690 output,
691 concat!(
692 "[dev.display] Status: OK\n",
693 "1 checked: ok 1, degraded 0, unhealthy 0, errors 0, no-info 0\n",
694 )
695 );
696
697 let cmd =
700 HealthCheckCommand::from_args(&["health-check"], &["--all", "-l", "ping"]).unwrap();
701 let (result, output) = run_health_check_test(
702 #[cfg(feature = "fdomain")]
703 Arc::clone(&client),
704 cmd,
705 |request: fdd::ManagerRequest| async move {
706 let nodes = vec![
707 node_info(1, "dev.display", Some("fuchsia-boot:///#meta/display.cm")),
708 node_info(2, "dev.touch", Some("fuchsia-boot:///#meta/touch.cm")),
709 node_info(3, "dev.i2c", Some("fuchsia-boot:///#meta/i2c.cm")),
710 node_info(4, "dev.spi", Some("fuchsia-boot:///#meta/spi.cm")),
711 ];
712 handle_all_request(request, nodes, |moniker| match moniker {
713 "dev.display" => Ok(fdf::HealthCheckResponse::default()),
714 "dev.touch" => Err(fdf::HealthCheckError::NotSupported),
715 "dev.i2c" => Ok(fdf::HealthCheckResponse {
716 conditions: Some(vec![fdf::HealthCondition {
717 status: Some(fdf::HealthStatus::Unhealthy),
718 reason_code: Some(String::from("BUS_LOCKUP")),
719 ..Default::default()
720 }]),
721 ..Default::default()
722 }),
723 "dev.spi" => Err(fdf::HealthCheckError::Timeout),
724 other => panic!("Unexpected moniker {other}"),
725 })
726 .await
727 },
728 )
729 .await;
730 assert!(result.is_err(), "UNHEALTHY and TIMEOUT outcomes must fail the sweep");
731 assert_eq!(
732 output,
733 concat!(
734 "[dev.display] Status: OK\n",
735 "[dev.touch] ERROR: NotSupported\n",
736 "[dev.i2c] Status: UNHEALTHY\n",
737 " Conditions:\n",
738 " [UNHEALTHY] BUS_LOCKUP\n",
739 "[dev.spi] ERROR: Timeout\n",
740 "4 checked: ok 1, degraded 0, unhealthy 1, errors 1, no-info 1\n",
741 )
742 );
743 }
744}