diff options
| -rw-r--r-- | crates/atuin-daemon/proto/history.proto | 27 | ||||
| -rw-r--r-- | crates/atuin-daemon/src/client.rs | 11 | ||||
| -rw-r--r-- | crates/atuin-daemon/src/components/history.rs | 81 | ||||
| -rw-r--r-- | crates/atuin-daemon/tests/lifecycle.rs | 50 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/daemon.rs | 17 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/history.rs | 407 |
6 files changed, 587 insertions, 6 deletions
diff --git a/crates/atuin-daemon/proto/history.proto b/crates/atuin-daemon/proto/history.proto index 2a45b7cf..59c12471 100644 --- a/crates/atuin-daemon/proto/history.proto +++ b/crates/atuin-daemon/proto/history.proto @@ -46,9 +46,36 @@ message ShutdownReply { bool accepted = 1; } +message TailHistoryRequest {} + +enum HistoryEventKind { + HISTORY_EVENT_KIND_UNSPECIFIED = 0; + HISTORY_EVENT_KIND_STARTED = 1; + HISTORY_EVENT_KIND_ENDED = 2; +} + +message HistoryEntry { + uint64 timestamp = 1; // nanosecond unix epoch + string id = 2; + string command = 3; + string cwd = 4; + string session = 5; + string hostname = 6; + string author = 7; + string intent = 8; + int64 exit = 9; + int64 duration = 10; +} + +message TailHistoryReply { + HistoryEventKind kind = 1; + HistoryEntry history = 2; +} + service History { rpc StartHistory(StartHistoryRequest) returns (StartHistoryReply); rpc EndHistory(EndHistoryRequest) returns (EndHistoryReply); + rpc TailHistory(TailHistoryRequest) returns (stream TailHistoryReply); rpc Status(StatusRequest) returns (StatusReply); rpc Shutdown(ShutdownRequest) returns (ShutdownReply); } diff --git a/crates/atuin-daemon/src/client.rs b/crates/atuin-daemon/src/client.rs index 2f492f6b..5f4ce20f 100644 --- a/crates/atuin-daemon/src/client.rs +++ b/crates/atuin-daemon/src/client.rs @@ -23,7 +23,8 @@ use crate::control::{ use crate::events::DaemonEvent; use crate::history::{ EndHistoryReply, EndHistoryRequest, ShutdownRequest, StartHistoryReply, StartHistoryRequest, - StatusReply, StatusRequest, history_client::HistoryClient as HistoryServiceClient, + StatusReply, StatusRequest, TailHistoryReply, TailHistoryRequest, + history_client::HistoryClient as HistoryServiceClient, }; use crate::search::{ FilterMode as RpcFilterMode, SearchContext as RpcSearchContext, SearchRequest, SearchResponse, @@ -140,6 +141,14 @@ impl HistoryClient { Ok(self.client.status(StatusRequest {}).await?.into_inner()) } + pub async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> { + Ok(self + .client + .tail_history(TailHistoryRequest {}) + .await? + .into_inner()) + } + pub async fn shutdown(&mut self) -> Result<bool> { let resp = self.client.shutdown(ShutdownRequest {}).await?.into_inner(); Ok(resp.accepted) diff --git a/crates/atuin-daemon/src/components/history.rs b/crates/atuin-daemon/src/components/history.rs index 23d48c5e..c82c8f94 100644 --- a/crates/atuin-daemon/src/components/history.rs +++ b/crates/atuin-daemon/src/components/history.rs @@ -2,7 +2,7 @@ //! //! Handles command history lifecycle (start/end) and provides the History gRPC service. -use std::sync::Arc; +use std::{pin::Pin, sync::Arc}; use atuin_client::{ database::Database, @@ -12,6 +12,7 @@ use atuin_client::{ use dashmap::DashMap; use eyre::Result; use time::OffsetDateTime; +use tokio_stream::Stream; use tonic::{Request, Response, Status}; use tracing::{Level, instrument}; @@ -19,8 +20,9 @@ use crate::{ daemon::{Component, DaemonHandle}, events::DaemonEvent, history::{ - EndHistoryReply, EndHistoryRequest, ShutdownReply, ShutdownRequest, StartHistoryReply, - StartHistoryRequest, StatusReply, StatusRequest, + EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, ShutdownReply, + ShutdownRequest, StartHistoryReply, StartHistoryRequest, StatusReply, StatusRequest, + TailHistoryReply, TailHistoryRequest, history_server::{History as HistorySvc, HistoryServer}, }, }; @@ -114,8 +116,28 @@ pub struct HistoryGrpcService { inner: Arc<HistoryComponentInner>, } +fn history_to_tail_reply(kind: HistoryEventKind, history: History) -> TailHistoryReply { + TailHistoryReply { + kind: kind as i32, + history: Some(HistoryEntry { + timestamp: history.timestamp.unix_timestamp_nanos() as u64, + id: history.id.0, + command: history.command, + cwd: history.cwd, + session: history.session, + hostname: history.hostname, + author: history.author, + intent: history.intent.unwrap_or_default(), + exit: history.exit, + duration: history.duration, + }), + } +} + #[tonic::async_trait] impl HistorySvc for HistoryGrpcService { + type TailHistoryStream = Pin<Box<dyn Stream<Item = Result<TailHistoryReply, Status>> + Send>>; + #[instrument(skip_all, level = Level::INFO)] async fn start_history( &self, @@ -136,6 +158,8 @@ impl HistorySvc for HistoryGrpcService { .cwd(req.cwd) .session(req.session) .hostname(req.hostname) + .author(req.author) + .intent(req.intent) .build() .into(); @@ -224,6 +248,57 @@ impl HistorySvc for HistoryGrpcService { } #[instrument(skip_all, level = Level::INFO)] + async fn tail_history( + &self, + _request: Request<TailHistoryRequest>, + ) -> Result<Response<Self::TailHistoryStream>, Status> { + let handle_guard = self.inner.handle.read().await; + let handle = handle_guard + .as_ref() + .cloned() + .ok_or_else(|| Status::internal("component not initialized"))?; + + let mut rx = handle.subscribe(); + let (tx, out_rx) = tokio::sync::mpsc::channel::<Result<TailHistoryReply, Status>>(128); + + tokio::spawn(async move { + loop { + let event = match rx.recv().await { + Ok(event) => event, + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + let _ = tx + .send(Err(Status::resource_exhausted(format!( + "tail stream lagged behind and dropped {skipped} events" + )))) + .await; + break; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }; + + let reply = match event { + DaemonEvent::HistoryStarted(history) => { + Some(history_to_tail_reply(HistoryEventKind::Started, history)) + } + DaemonEvent::HistoryEnded(history) => { + Some(history_to_tail_reply(HistoryEventKind::Ended, history)) + } + _ => None, + }; + + if let Some(reply) = reply + && tx.send(Ok(reply)).await.is_err() + { + break; + } + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(out_rx); + Ok(Response::new(Box::pin(stream))) + } + + #[instrument(skip_all, level = Level::INFO)] async fn status( &self, _request: Request<StatusRequest>, diff --git a/crates/atuin-daemon/tests/lifecycle.rs b/crates/atuin-daemon/tests/lifecycle.rs index 3b6952de..4a91e5cb 100644 --- a/crates/atuin-daemon/tests/lifecycle.rs +++ b/crates/atuin-daemon/tests/lifecycle.rs @@ -146,6 +146,56 @@ mod unix { } #[tokio::test] + async fn test_tail_history_streams_started_and_ended_events() { + use atuin_client::history::History; + use atuin_daemon::history::HistoryEventKind; + + let (mut client, _handle, _tmp) = start_test_daemon().await; + let mut stream = client.tail_history().await.unwrap(); + + let history = History::daemon() + .timestamp(time::OffsetDateTime::now_utc()) + .command("git status".to_string()) + .cwd("/tmp/repo".to_string()) + .session("tail-session".to_string()) + .hostname("test-host:ellie".to_string()) + .author("claude".to_string()) + .intent("inspect repository state".to_string()) + .build() + .into(); + + let start_reply = client.start_history(history).await.unwrap(); + + let started = stream.message().await.unwrap().unwrap(); + assert_eq!( + HistoryEventKind::try_from(started.kind).unwrap(), + HistoryEventKind::Started + ); + let started_history = started.history.unwrap(); + assert_eq!(started_history.id, start_reply.id); + assert_eq!(started_history.command, "git status"); + assert_eq!(started_history.cwd, "/tmp/repo"); + assert_eq!(started_history.hostname, "test-host:ellie"); + assert_eq!(started_history.author, "claude"); + assert_eq!(started_history.intent, "inspect repository state"); + + client + .end_history(start_reply.id.clone(), 1_000_000, 0) + .await + .unwrap(); + + let ended = stream.message().await.unwrap().unwrap(); + assert_eq!( + HistoryEventKind::try_from(ended.kind).unwrap(), + HistoryEventKind::Ended + ); + let ended_history = ended.history.unwrap(); + assert_eq!(ended_history.id, start_reply.id); + assert_eq!(ended_history.exit, 0); + assert_eq!(ended_history.duration, 1_000_000); + } + + #[tokio::test] async fn test_end_unknown_history_fails() { let (mut client, _handle, _tmp) = start_test_daemon().await; diff --git a/crates/atuin/src/command/client/daemon.rs b/crates/atuin/src/command/client/daemon.rs index 64547505..7847ea2d 100644 --- a/crates/atuin/src/command/client/daemon.rs +++ b/crates/atuin/src/command/client/daemon.rs @@ -465,6 +465,23 @@ pub async fn end_history(settings: &Settings, id: String, duration: u64, exit: i Ok(()) } +pub async fn tail_client(settings: &Settings) -> Result<HistoryClient> { + match probe(settings).await { + Probe::Ready(client) => return Ok(client), + Probe::NeedsRestart(reason) if !settings.daemon.autostart => { + bail!("{reason}. Enable `daemon.autostart = true` or restart the daemon manually"); + } + Probe::Unreachable(err) if is_legacy_daemon_error(&err) => { + return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE)); + } + Probe::Unreachable(err) if !settings.daemon.autostart => return Err(err), + Probe::Unreachable(err) if !should_retry_after_error(&err) => return Err(err), + Probe::NeedsRestart(_) | Probe::Unreachable(_) => {} + } + + restart_daemon(settings).await +} + async fn status_cmd(settings: &Settings) -> Result<()> { match probe(settings).await { Probe::Ready(mut client) => { diff --git a/crates/atuin/src/command/client/history.rs b/crates/atuin/src/command/client/history.rs index 4d81d144..39e2c9f6 100644 --- a/crates/atuin/src/command/client/history.rs +++ b/crates/atuin/src/command/client/history.rs @@ -7,11 +7,19 @@ use std::{ use atuin_common::utils::{self, Escapable as _}; use clap::Subcommand; -use eyre::{Context, Result}; +use eyre::{Context, Result, bail}; use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; #[cfg(feature = "daemon")] -use atuin_daemon::emit_event; +use colored::Colorize; +#[cfg(feature = "daemon")] +use serde::Serialize; + +#[cfg(feature = "daemon")] +use atuin_daemon::{ + emit_event, + history::{HistoryEventKind, TailHistoryReply}, +}; use atuin_client::{ database::{Database, Sqlite, current_context}, @@ -64,6 +72,9 @@ pub enum Cmd { duration: Option<u64>, }, + /// Stream history events from the daemon as they are received + Tail, + /// List all items in history List { #[arg(long, short)] @@ -364,6 +375,307 @@ fn parse_fmt(format: &str) -> ParsedFmt<'_> { } } +#[cfg(feature = "daemon")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TailKind { + Started, + Ended, +} + +#[cfg(feature = "daemon")] +#[derive(Clone, Debug, Eq, PartialEq)] +struct TailEvent { + kind: TailKind, + history: History, +} + +#[cfg(feature = "daemon")] +#[derive(Serialize)] +struct TailJsonEvent<'a> { + event: &'static str, + history: TailJsonHistory<'a>, +} + +#[cfg(feature = "daemon")] +#[derive(Serialize)] +struct TailJsonHistory<'a> { + id: &'a str, + timestamp: String, + timestamp_unix_ns: u64, + command: &'a str, + cwd: &'a str, + session: &'a str, + hostname: &'a str, + host: &'a str, + user: &'a str, + author: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + intent: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + exit: Option<i64>, + #[serde(skip_serializing_if = "Option::is_none")] + duration_ns: Option<i64>, + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + success: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + finished_at: Option<String>, +} + +#[cfg(feature = "daemon")] +impl TailEvent { + fn from_proto(reply: TailHistoryReply) -> Result<Self> { + let history = reply + .history + .ok_or_else(|| eyre::eyre!("daemon sent a history tail event without history"))?; + let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(history.timestamp)) + .context("invalid daemon history timestamp")?; + let kind = match HistoryEventKind::try_from(reply.kind) + .unwrap_or(HistoryEventKind::Unspecified) + { + HistoryEventKind::Started => TailKind::Started, + HistoryEventKind::Ended => TailKind::Ended, + HistoryEventKind::Unspecified => bail!("daemon sent an unspecified history tail event"), + }; + + Ok(Self { + kind, + history: History { + id: history.id.into(), + timestamp, + duration: history.duration, + exit: history.exit, + command: history.command, + cwd: history.cwd, + session: history.session, + hostname: history.hostname, + author: history.author, + intent: normalize_optional_field(&history.intent), + deleted_at: None, + }, + }) + } + + fn render(&self, tty: bool, tz: Timezone) -> Result<String> { + if tty { + Ok(self.render_pretty(tz)) + } else { + let mut json = self.render_json(tz)?; + json.push('\n'); + Ok(json) + } + } + + fn render_json(&self, tz: Timezone) -> Result<String> { + let payload = TailJsonEvent { + event: self.kind.as_str(), + history: TailJsonHistory { + id: &self.history.id.0, + timestamp: format_history_time(self.history.timestamp, tz)?, + timestamp_unix_ns: u64::try_from(self.history.timestamp.unix_timestamp_nanos()) + .context("history timestamp predates unix epoch")?, + command: &self.history.command, + cwd: &self.history.cwd, + session: &self.history.session, + hostname: &self.history.hostname, + host: self.host(), + user: self.user(), + author: &self.history.author, + intent: self.history.intent.as_deref(), + exit: self.exit_value(), + duration_ns: self.duration_value(), + duration: self.duration_value().map(format_duration_ns), + success: self.success_value(), + finished_at: self + .finished_at() + .map(|time| format_history_time(time, tz)) + .transpose()?, + }, + }; + + Ok(serde_json::to_string(&payload)?) + } + + fn render_pretty(&self, tz: Timezone) -> String { + let mut out = String::new(); + let border = match self.kind { + TailKind::Started => "-".repeat(72).bright_blue().to_string(), + TailKind::Ended if self.history.exit == 0 => "-".repeat(72).bright_green().to_string(), + TailKind::Ended => "-".repeat(72).bright_red().to_string(), + }; + + out.push_str(&border); + out.push('\n'); + + let command = self.history.command.trim(); + let escaped_command = command.escape_control(); + let mut command_lines = escaped_command.lines(); + let header = format!( + "{} {}", + self.kind.badge(self.history.exit), + command_lines.next().unwrap_or_default().bold() + ); + out.push_str(&header); + out.push('\n'); + + for line in command_lines { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } + + push_pretty_field( + &mut out, + "start", + &format_history_time(self.history.timestamp, tz) + .unwrap_or_else(|_| "invalid".to_owned()), + ); + push_pretty_field(&mut out, "history", &self.history.id.0); + push_pretty_field(&mut out, "session", &self.history.session); + push_pretty_field(&mut out, "exit", &self.exit_display()); + push_pretty_field(&mut out, "duration", &self.duration_display()); + + out.push('\n'); + + push_pretty_field(&mut out, "cwd", &self.history.cwd); + push_pretty_field(&mut out, "hostname", &self.history.hostname); + push_pretty_field(&mut out, "host", self.host()); + push_pretty_field(&mut out, "user", self.user()); + push_pretty_field(&mut out, "author", &self.history.author); + + if let Some(intent) = self.history.intent.as_deref() { + push_pretty_field(&mut out, "intent", intent); + } + + if let Some(finished) = self.finished_at() { + let finished = + format_history_time(finished, tz).unwrap_or_else(|_| "invalid".to_owned()); + push_pretty_field(&mut out, "finished", &finished); + } + + out.push_str(&border); + out.push_str("\n\n"); + out + } + + fn host(&self) -> &str { + self.history + .hostname + .split_once(':') + .map_or(self.history.hostname.as_str(), |(host, _)| host) + } + + fn user(&self) -> &str { + self.history + .hostname + .split_once(':') + .map_or("", |(_, user)| user) + } + + fn exit_value(&self) -> Option<i64> { + matches!(self.kind, TailKind::Ended).then_some(self.history.exit) + } + + fn duration_value(&self) -> Option<i64> { + matches!(self.kind, TailKind::Ended).then_some(self.history.duration) + } + + fn success_value(&self) -> Option<bool> { + matches!(self.kind, TailKind::Ended).then_some(self.history.exit == 0) + } + + fn finished_at(&self) -> Option<OffsetDateTime> { + self.duration_value() + .filter(|duration| *duration >= 0) + .map(time::Duration::nanoseconds) + .and_then(|duration| self.history.timestamp.checked_add(duration)) + } + + fn exit_display(&self) -> String { + match self.exit_value() { + Some(0) => "0 (success)".bright_green().to_string(), + Some(code) => format!("{code} (failure)").bright_red().to_string(), + None => "pending".bright_yellow().to_string(), + } + } + + fn duration_display(&self) -> String { + match self.duration_value() { + Some(duration) if duration >= 0 => format_duration_ns(duration), + Some(_) => "unknown".bright_yellow().to_string(), + None => "running".bright_yellow().to_string(), + } + } +} + +#[cfg(feature = "daemon")] +impl TailKind { + const fn as_str(self) -> &'static str { + match self { + Self::Started => "started", + Self::Ended => "ended", + } + } + + fn badge(self, exit: i64) -> colored::ColoredString { + match self { + Self::Started => "STARTED".bold().bright_blue(), + Self::Ended if exit == 0 => "ENDED".bold().bright_green(), + Self::Ended => "ENDED".bold().bright_red(), + } + } +} + +#[cfg(feature = "daemon")] +fn format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> { + Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?) +} + +#[cfg(feature = "daemon")] +fn format_duration_ns(duration_ns: i64) -> String { + struct F(Duration); + impl Display for F { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + format_duration_into(self.0, f) + } + } + + F(Duration::from_nanos(duration_ns.max(0).cast_unsigned())).to_string() +} + +#[cfg(feature = "daemon")] +fn push_pretty_field(out: &mut String, label: &str, value: &str) { + out.push_str(" "); + let label = format!("{label}:"); + out.push_str(&label.bright_cyan().bold().to_string()); + if label.len() < 10 { + out.push_str(&" ".repeat(10 - label.len())); + } + + let mut lines = value.lines(); + if let Some(first) = lines.next() { + out.push_str(first); + } + out.push('\n'); + + for line in lines { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } +} + +#[cfg(feature = "daemon")] +fn normalize_optional_field(value: &str) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + impl Cmd { fn normalize_command_for_storage<'a>(command: &'a str, settings: &Settings) -> &'a str { if !settings.strip_trailing_whitespace { @@ -559,6 +871,28 @@ impl Cmd { Ok(()) } + #[cfg(feature = "daemon")] + async fn handle_tail(settings: &Settings) -> Result<()> { + let tty = std::io::stdout().is_terminal(); + let mut client = daemon::tail_client(settings).await?; + let mut stream = client.tail_history().await?; + let stdout = std::io::stdout(); + + while let Some(reply) = stream.message().await? { + let event = TailEvent::from_proto(reply)?; + let rendered = event.render(tty, settings.timezone)?; + let mut out = stdout.lock(); + + match out.write_all(rendered.as_bytes()) { + Ok(()) => out.flush()?, + Err(err) if err.kind() == io::ErrorKind::BrokenPipe => break, + Err(err) => return Err(err.into()), + } + } + + Ok(()) + } + #[allow(clippy::too_many_arguments)] #[allow(clippy::fn_params_excessive_bools)] async fn handle_list( @@ -721,6 +1055,7 @@ impl Cmd { Ok(()) } + #[allow(clippy::too_many_lines)] pub async fn run(self, settings: &Settings) -> Result<()> { let context = current_context().await?; @@ -738,10 +1073,22 @@ impl Cmd { return Self::handle_daemon_end(settings, &id, exit, duration).await; } + Self::Tail => { + return Self::handle_tail(settings).await; + } + _ => {} } } + if matches!(self, Self::Tail) { + #[cfg(feature = "daemon")] + bail!("`atuin history tail` requires `daemon.enabled = true`"); + + #[cfg(not(feature = "daemon"))] + bail!("`atuin history tail` requires Atuin to be built with the `daemon` feature"); + } + let db_path = PathBuf::from(settings.db_path.as_str()); let record_store_path = PathBuf::from(settings.record_store_path.as_str()); @@ -764,6 +1111,7 @@ impl Cmd { Self::End { id, exit, duration } => { Self::handle_end(&db, store, history_store, settings, &id, exit, duration).await } + Self::Tail => unreachable!("tail handled before database initialization"), Self::List { session, cwd, @@ -854,6 +1202,9 @@ impl Cmd { #[cfg(test)] mod tests { + #[cfg(feature = "daemon")] + use time::macros::datetime; + use super::*; #[test] @@ -935,4 +1286,56 @@ mod tests { assert!(std::panic::catch_unwind(|| parse_fmt("{command}")).is_ok()); assert!(std::panic::catch_unwind(|| parse_fmt("{time} - {command}")).is_ok()); } + + #[cfg(feature = "daemon")] + fn sample_tail_event(kind: TailKind) -> TailEvent { + TailEvent { + kind, + history: History { + id: "history-id".to_owned().into(), + timestamp: datetime!(2026-04-09 17:18:19 UTC), + duration: 12_345_678, + exit: 0, + command: "git status".to_owned(), + cwd: "/tmp/repo".to_owned(), + session: "session-id".to_owned(), + hostname: "host:ellie".to_owned(), + author: "claude".to_owned(), + intent: Some("inspect repository state".to_owned()), + deleted_at: None, + }, + } + } + + #[cfg(feature = "daemon")] + #[test] + fn test_tail_json_output_contains_history_fields() { + let json = sample_tail_event(TailKind::Ended) + .render(false, Timezone(time::UtcOffset::UTC)) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(value["event"], "ended"); + assert_eq!(value["history"]["id"], "history-id"); + assert_eq!(value["history"]["duration_ns"], 12_345_678); + assert_eq!(value["history"]["success"], true); + assert!(value.get("record").is_none()); + } + + #[cfg(feature = "daemon")] + #[test] + fn test_tail_pretty_output_shows_pending_fields_for_started_events() { + let rendered = sample_tail_event(TailKind::Started) + .render(true, Timezone(time::UtcOffset::UTC)) + .unwrap(); + let plain = regex::Regex::new(r"\x1b\[[0-9;]*m") + .unwrap() + .replace_all(&rendered, ""); + + assert!(plain.contains("STARTED git status")); + assert!(plain.contains("exit:")); + assert!(plain.contains("pending")); + assert!(plain.contains("duration:")); + assert!(plain.contains("running")); + } } |
