use std::sync::Arc;
#[cfg(feature = "screen")]
use std::sync::{Mutex as StdMutex, MutexGuard};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
use crate::backend::ChildExit;
#[cfg(unix)]
use crate::backend::{AsyncPty, PtyConfig, PtySpawner};
#[cfg(windows)]
use crate::backend::{PtyConfig, PtySpawner, WindowsAsyncPty};
use crate::config::SessionConfig;
use crate::dialog::{Dialog, DialogExecutor, DialogResult};
use crate::error::{ExpectError, Result};
use crate::expect::{
ExpectState, HandlerAction, MatchResult, Matcher, Pattern, PatternManager, PatternSet,
};
use crate::interact::InteractBuilder;
#[cfg(feature = "screen")]
use crate::screen::Screen;
use crate::types::{ControlChar, Dimensions, Match, ProcessExitStatus, SessionId, SessionState};
pub type OutputTap = Arc<dyn Fn(&[u8]) + Send + Sync>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TapId(u64);
impl std::fmt::Display for TapId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "tap#{}", self.0)
}
}
#[cfg(feature = "screen")]
fn lock_screen(screen: &Arc<StdMutex<Screen>>) -> MutexGuard<'_, Screen> {
match screen.lock() {
Ok(g) => g,
Err(poison) => {
tracing::warn!("screen mutex was poisoned; recovering inner state");
poison.into_inner()
}
}
}
pub struct Session<T: AsyncReadExt + AsyncWriteExt + Unpin + Send> {
transport: Arc<Mutex<T>>,
config: SessionConfig,
matcher: Matcher,
pattern_manager: PatternManager,
state: SessionState,
id: SessionId,
eof: bool,
output_taps: Vec<(TapId, OutputTap)>,
next_tap_id: u64,
#[cfg(feature = "screen")]
screen: Option<Arc<StdMutex<Screen>>>,
#[cfg(feature = "screen")]
screen_tap_id: Option<TapId>,
#[cfg(feature = "screen")]
screen_poll_interval: Duration,
}
fn write_error_means_closed(err: &std::io::Error) -> bool {
use std::io::ErrorKind;
if matches!(
err.kind(),
ErrorKind::BrokenPipe | ErrorKind::ConnectionReset
) {
return true;
}
#[cfg(unix)]
if err.raw_os_error() == Some(libc::EIO) {
return true;
}
false
}
impl<T: AsyncReadExt + AsyncWriteExt + Unpin + Send> Session<T> {
pub fn new(transport: T, config: SessionConfig) -> Self {
let buffer_size = config.buffer.max_size;
let mut matcher = Matcher::new(buffer_size);
matcher.set_default_timeout(config.timeout.default);
Self {
transport: Arc::new(Mutex::new(transport)),
config,
matcher,
pattern_manager: PatternManager::new(),
state: SessionState::Starting,
id: SessionId::new(),
eof: false,
output_taps: Vec::new(),
next_tap_id: 0,
#[cfg(feature = "screen")]
screen: None,
#[cfg(feature = "screen")]
screen_tap_id: None,
#[cfg(feature = "screen")]
screen_poll_interval: Duration::from_millis(50),
}
}
#[cfg(feature = "screen")]
pub const fn set_screen_poll_interval(&mut self, interval: Duration) {
self.screen_poll_interval = interval;
}
#[cfg(feature = "screen")]
#[must_use]
pub const fn screen_poll_interval(&self) -> Duration {
self.screen_poll_interval
}
pub fn add_output_tap<F>(&mut self, f: F) -> TapId
where
F: Fn(&[u8]) + Send + Sync + 'static,
{
let id = TapId(self.next_tap_id);
self.next_tap_id += 1;
self.output_taps.push((id, Arc::new(f)));
id
}
pub fn remove_output_tap(&mut self, id: TapId) -> bool {
let len_before = self.output_taps.len();
self.output_taps.retain(|(existing, _)| *existing != id);
self.output_taps.len() != len_before
}
pub fn output_tap_callbacks(&self) -> impl Iterator<Item = &OutputTap> {
self.output_taps.iter().map(|(_, cb)| cb)
}
#[cfg(feature = "screen")]
pub fn attach_screen(&mut self) {
let (cols, rows) = self.config.dimensions;
self.attach_screen_with_dims(rows, cols);
}
#[cfg(feature = "screen")]
pub fn attach_screen_with_dims(&mut self, rows: u16, cols: u16) {
self.detach_screen();
let screen = Arc::new(StdMutex::new(Screen::new(rows as usize, cols as usize)));
let screen_for_tap = screen.clone();
let id = self.add_output_tap(move |chunk| {
lock_screen(&screen_for_tap).process(chunk);
});
self.screen = Some(screen);
self.screen_tap_id = Some(id);
}
#[cfg(feature = "screen")]
pub fn attach_screen_with_scrollback(&mut self, scrollback_lines: usize) {
let (cols, rows) = self.config.dimensions;
self.detach_screen();
let screen = Arc::new(StdMutex::new(Screen::with_scrollback(
rows as usize,
cols as usize,
scrollback_lines,
)));
let screen_for_tap = screen.clone();
let id = self.add_output_tap(move |chunk| {
lock_screen(&screen_for_tap).process(chunk);
});
self.screen = Some(screen);
self.screen_tap_id = Some(id);
}
#[cfg(feature = "screen")]
pub fn on_screen_line_scrolled_out<F>(&mut self, callback: F) -> bool
where
F: FnMut(&crate::screen::Row) + Send + 'static,
{
if let Some(screen) = self.screen.as_ref() {
lock_screen(screen).on_line_scrolled_out(callback);
true
} else {
false
}
}
#[cfg(feature = "screen")]
pub fn detach_screen(&mut self) -> bool {
if let Some(id) = self.screen_tap_id.take() {
self.remove_output_tap(id);
}
self.screen.take().is_some()
}
#[cfg(feature = "screen")]
#[must_use]
pub const fn screen(&self) -> Option<&Arc<StdMutex<Screen>>> {
self.screen.as_ref()
}
#[must_use]
pub const fn id(&self) -> &SessionId {
&self.id
}
#[must_use]
pub const fn state(&self) -> SessionState {
self.state
}
#[must_use]
pub const fn config(&self) -> &SessionConfig {
&self.config
}
#[must_use]
pub const fn is_eof(&self) -> bool {
self.eof
}
#[must_use]
pub fn buffer(&mut self) -> String {
self.matcher.buffer_str()
}
pub fn clear_buffer(&mut self) {
self.matcher.clear();
}
#[must_use]
pub const fn pattern_manager(&self) -> &PatternManager {
&self.pattern_manager
}
pub const fn pattern_manager_mut(&mut self) -> &mut PatternManager {
&mut self.pattern_manager
}
pub const fn set_state(&mut self, state: SessionState) {
self.state = state;
}
#[allow(clippy::significant_drop_tightening)]
pub async fn send(&mut self, data: &[u8]) -> Result<()> {
if matches!(self.state, SessionState::Closed | SessionState::Exited(_)) {
return Err(ExpectError::SessionClosed);
}
let result = {
let mut transport = self.transport.lock().await;
match transport.write_all(data).await {
Ok(()) => transport.flush().await,
Err(e) => Err(e),
}
};
match result {
Ok(()) => Ok(()),
Err(e) if write_error_means_closed(&e) => {
self.state = SessionState::Closed;
Err(ExpectError::SessionClosed)
}
Err(e) => Err(ExpectError::io_context("writing to process", e)),
}
}
pub async fn send_str(&mut self, s: &str) -> Result<()> {
self.send(s.as_bytes()).await
}
pub async fn send_line(&mut self, line: &str) -> Result<()> {
let line_ending = self.config.line_ending.as_str();
let data = format!("{line}{line_ending}");
self.send(data.as_bytes()).await
}
pub async fn send_control(&mut self, ctrl: ControlChar) -> Result<()> {
self.send(&[ctrl.as_byte()]).await
}
pub async fn send_shift_tab(&mut self) -> Result<()> {
self.send(b"\x1b[Z").await
}
pub async fn send_paste(&mut self, text: &str) -> Result<()> {
if memchr::memmem::find(text.as_bytes(), b"\x1b[201~").is_some() {
return Err(ExpectError::InvalidInput {
api: "send_paste".to_string(),
reason:
"input contains the bracketed-paste end marker (\\x1b[201~); use send() for raw bytes that include this sequence"
.to_string(),
});
}
self.send(b"\x1b[200~").await?;
self.send(text.as_bytes()).await?;
self.send(b"\x1b[201~").await
}
pub async fn expect(&mut self, pattern: impl Into<Pattern>) -> Result<Match> {
let patterns = PatternSet::from_patterns(vec![pattern.into()]);
self.expect_any(&patterns).await
}
pub async fn expect_any(&mut self, patterns: &PatternSet) -> Result<Match> {
let timeout = self.matcher.get_timeout(patterns);
let state = ExpectState::new(patterns.clone(), timeout);
loop {
if let Some((_, action, pattern)) = self
.pattern_manager
.check_before(&self.matcher.buffer_str())
&& let Some(outcome) = self.apply_ambient_action(action, &pattern).await?
{
return Ok(outcome);
}
if let Some(result) = self.matcher.try_match_any(patterns) {
return Ok(self.matcher.consume_match(&result));
}
if let Some((_, action, pattern)) =
self.pattern_manager.check_after(&self.matcher.buffer_str())
&& let Some(outcome) = self.apply_ambient_action(action, &pattern).await?
{
return Ok(outcome);
}
if state.is_timed_out() {
return Err(ExpectError::Timeout {
duration: timeout,
pattern: patterns
.iter()
.next()
.map(|p| p.pattern.as_str().to_string())
.unwrap_or_default(),
buffer: self.matcher.buffer_str(),
});
}
if self.eof {
if state.expects_eof() {
return Ok(Match::new(
0,
String::new(),
self.matcher.buffer_str(),
String::new(),
));
}
return Err(ExpectError::Eof {
buffer: self.matcher.buffer_str(),
});
}
self.read_with_timeout(state.remaining_time()).await?;
}
}
async fn apply_ambient_action(
&mut self,
action: HandlerAction,
pattern: &Pattern,
) -> Result<Option<Match>> {
match action {
HandlerAction::Continue => Ok(None),
HandlerAction::Respond(s) => {
self.consume_ambient(pattern);
self.send_str(&s).await?;
Ok(None)
}
HandlerAction::Return(s) => {
let (before, after) = match self.consume_ambient(pattern) {
Some(m) => (m.before, m.after),
None => (String::new(), self.matcher.buffer_str()),
};
Ok(Some(Match::new(0, s, before, after)))
}
HandlerAction::Abort(msg) => Err(ExpectError::PatternNotFound {
pattern: msg,
buffer: self.matcher.buffer_str(),
}),
}
}
fn consume_ambient(&mut self, pattern: &Pattern) -> Option<Match> {
let result = self.matcher.try_match(pattern)?;
Some(self.matcher.consume_match(&result))
}
pub async fn expect_timeout(
&mut self,
pattern: impl Into<Pattern>,
timeout: Duration,
) -> Result<Match> {
let pattern = pattern.into();
let mut patterns = PatternSet::new();
patterns.add(pattern).add(Pattern::timeout(timeout));
self.expect_any(&patterns).await
}
#[cfg(feature = "screen")]
pub async fn expect_screen_contains(&mut self, needle: &str, timeout: Duration) -> Result<()> {
let Some(screen) = self.screen.clone() else {
return Err(ExpectError::ScreenNotAttached);
};
let start = tokio::time::Instant::now();
let poll = self.screen_poll_interval;
loop {
if lock_screen(&screen).query().contains(needle) {
return Ok(());
}
if self.eof {
return Err(ExpectError::Eof {
buffer: lock_screen(&screen).text(),
});
}
let elapsed = start.elapsed();
if elapsed >= timeout {
return Err(ExpectError::Timeout {
duration: timeout,
pattern: needle.to_string(),
buffer: lock_screen(&screen).text(),
});
}
let remaining = timeout.saturating_sub(elapsed);
self.read_with_timeout(poll.min(remaining)).await?;
}
}
#[cfg(feature = "screen")]
pub async fn wait_screen_not_contains(
&mut self,
needle: &str,
timeout: Duration,
) -> Result<()> {
let Some(screen) = self.screen.clone() else {
return Err(ExpectError::ScreenNotAttached);
};
let start = tokio::time::Instant::now();
let poll = self.screen_poll_interval;
loop {
if !lock_screen(&screen).query().contains(needle) {
return Ok(());
}
if self.eof {
return Ok(());
}
let elapsed = start.elapsed();
if elapsed >= timeout {
return Err(ExpectError::Timeout {
duration: timeout,
pattern: format!("!{needle}"),
buffer: lock_screen(&screen).text(),
});
}
let remaining = timeout.saturating_sub(elapsed);
self.read_with_timeout(poll.min(remaining)).await?;
}
}
#[cfg(feature = "screen")]
pub async fn wait_screen_stable(
&mut self,
quiet_period: Duration,
max_wait: Duration,
) -> Result<()> {
let Some(screen) = self.screen.clone() else {
return Err(ExpectError::ScreenNotAttached);
};
let start = tokio::time::Instant::now();
let poll = self.screen_poll_interval;
let mut last_revision = lock_screen(&screen).revision();
let mut last_change = tokio::time::Instant::now();
loop {
if last_change.elapsed() >= quiet_period {
return Ok(());
}
if self.eof {
return Ok(());
}
if start.elapsed() >= max_wait {
return Err(ExpectError::Timeout {
duration: max_wait,
pattern: "<screen stability>".to_string(),
buffer: lock_screen(&screen).text(),
});
}
self.read_with_timeout(poll).await?;
let current_revision = lock_screen(&screen).revision();
if current_revision != last_revision {
last_revision = current_revision;
last_change = tokio::time::Instant::now();
}
}
}
async fn read_with_timeout(&mut self, timeout: Duration) -> Result<usize> {
let mut buf = [0u8; 4096];
let mut transport = self.transport.lock().await;
match tokio::time::timeout(timeout, transport.read(&mut buf)).await {
Ok(Ok(0)) => {
self.eof = true;
Ok(0)
}
Ok(Ok(n)) => {
self.matcher.append(&buf[..n]);
for (id, tap) in &self.output_taps {
let tap_clone = tap.clone();
let chunk = &buf[..n];
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tap_clone(chunk)));
if result.is_err() {
tracing::warn!(
%id,
"output tap panicked; the panic was caught and other taps continue"
);
}
}
Ok(n)
}
Ok(Err(e)) => {
if is_pty_eof_error(&e) {
self.eof = true;
Ok(0)
} else {
Err(ExpectError::io_context("reading from process", e))
}
}
Err(_) => {
Ok(0)
}
}
}
#[must_use]
pub fn check(&mut self, pattern: &Pattern) -> Option<MatchResult> {
self.matcher.try_match(pattern)
}
#[must_use]
pub const fn transport(&self) -> &Arc<Mutex<T>> {
&self.transport
}
#[must_use]
pub fn interact(&self) -> InteractBuilder<'_, T>
where
T: 'static,
{
let taps: Vec<OutputTap> = self
.output_taps
.iter()
.map(|(_, tap)| tap.clone())
.collect();
InteractBuilder::new(&self.transport, taps)
}
pub async fn run_dialog(&mut self, dialog: &Dialog) -> Result<DialogResult> {
let executor = DialogExecutor::default();
executor.execute(self, dialog).await
}
pub async fn run_dialog_with(
&mut self,
dialog: &Dialog,
executor: &DialogExecutor,
) -> Result<DialogResult> {
executor.execute(self, dialog).await
}
pub async fn expect_eof(&mut self) -> Result<Match> {
self.expect(Pattern::eof()).await
}
pub async fn expect_eof_timeout(&mut self, timeout: Duration) -> Result<Match> {
let mut patterns = PatternSet::new();
patterns.add(Pattern::eof()).add(Pattern::timeout(timeout));
self.expect_any(&patterns).await
}
pub async fn run_script<I, S>(&mut self, commands: I, prompt: Pattern) -> Result<Vec<Match>>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut results = Vec::new();
for cmd in commands {
self.send_line(cmd.as_ref()).await?;
let result = self.expect(prompt.clone()).await?;
results.push(result);
}
Ok(results)
}
pub async fn run_script_timeout<I, S>(
&mut self,
commands: I,
prompt: Pattern,
timeout: Duration,
) -> Result<Vec<Match>>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut results = Vec::new();
for cmd in commands {
self.send_line(cmd.as_ref()).await?;
let result = self.expect_timeout(prompt.clone(), timeout).await?;
results.push(result);
}
Ok(results)
}
pub async fn run_script_with_results<I, S>(
&mut self,
commands: I,
prompt: Pattern,
) -> (Vec<Match>, Option<ExpectError>)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut results = Vec::new();
for cmd in commands {
match self.send_line(cmd.as_ref()).await {
Ok(()) => {}
Err(e) => return (results, Some(e)),
}
match self.expect(prompt.clone()).await {
Ok(result) => results.push(result),
Err(e) => return (results, Some(e)),
}
}
(results, None)
}
}
impl<T: AsyncReadExt + AsyncWriteExt + Unpin + Send + ChildExit> Session<T> {
pub async fn wait(&mut self) -> Result<ProcessExitStatus> {
while !self.eof {
if self.read_with_timeout(Duration::from_millis(100)).await? == 0 && !self.eof {
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
let status = self.reap_exit_status().await;
self.state = SessionState::Exited(status);
Ok(status)
}
pub async fn wait_timeout(&mut self, timeout: Duration) -> Result<ProcessExitStatus> {
let deadline = tokio::time::Instant::now() + timeout;
while !self.eof {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(ExpectError::timeout(
timeout,
"<EOF>",
self.matcher.buffer_str(),
));
}
let poll_timeout = remaining.min(Duration::from_millis(100));
if self.read_with_timeout(poll_timeout).await? == 0 && !self.eof {
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
let status = self.reap_exit_status().await;
self.state = SessionState::Exited(status);
Ok(status)
}
async fn reap_exit_status(&self) -> ProcessExitStatus {
const ATTEMPTS: u32 = 20;
for _ in 0..ATTEMPTS {
let status = self.transport.lock().await.try_exit_status();
if let Some(status) = status {
return status;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
ProcessExitStatus::Unknown
}
}
impl<T: AsyncReadExt + AsyncWriteExt + Unpin + Send> std::fmt::Debug for Session<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session")
.field("id", &self.id)
.field("state", &self.state)
.field("eof", &self.eof)
.finish_non_exhaustive()
}
}
#[cfg(unix)]
impl Session<AsyncPty> {
pub async fn spawn(command: &str, args: &[&str]) -> Result<Self> {
Self::spawn_with_config(command, args, SessionConfig::default()).await
}
pub async fn spawn_with_config(
command: &str,
args: &[&str],
config: SessionConfig,
) -> Result<Self> {
let pty_config = PtyConfig::from(&config);
let spawner = PtySpawner::with_config(pty_config);
let args_owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
let handle = spawner.spawn(command, &args_owned).await?;
let async_pty = AsyncPty::from_handle(handle)
.map_err(|e| ExpectError::io_context("creating async PTY wrapper", e))?;
let mut session = Self::new(async_pty, config);
session.state = SessionState::Running;
Ok(session)
}
#[must_use]
pub fn pid(&self) -> u32 {
if let Ok(transport) = self.transport.try_lock() {
transport.pid()
} else {
0
}
}
pub async fn resize_pty(&mut self, cols: u16, rows: u16) -> Result<()> {
{
let mut transport = self.transport.lock().await;
transport.resize(cols, rows)?;
}
self.config.dimensions = (cols, rows);
#[cfg(feature = "screen")]
if let Some(screen) = self.screen.as_ref()
&& let Ok(mut s) = screen.lock()
{
s.resize(rows as usize, cols as usize);
}
Ok(())
}
pub fn signal(&self, signal: i32) -> Result<()> {
if let Ok(mut transport) = self.transport.try_lock() {
transport.signal(signal)
} else {
Err(ExpectError::io_context(
"sending signal to process",
std::io::Error::new(std::io::ErrorKind::WouldBlock, "transport is locked"),
))
}
}
pub fn kill(&self) -> Result<()> {
if let Ok(mut transport) = self.transport.try_lock() {
transport.kill()
} else {
Err(ExpectError::io_context(
"killing process",
std::io::Error::new(std::io::ErrorKind::WouldBlock, "transport is locked"),
))
}
}
#[must_use]
pub fn is_running(&self) -> bool {
if let Ok(mut transport) = self.transport.try_lock() {
transport.is_running()
} else {
true }
}
}
#[cfg(windows)]
impl Session<WindowsAsyncPty> {
pub async fn spawn(command: &str, args: &[&str]) -> Result<Self> {
Self::spawn_with_config(command, args, SessionConfig::default()).await
}
pub async fn spawn_with_config(
command: &str,
args: &[&str],
config: SessionConfig,
) -> Result<Self> {
let pty_config = PtyConfig::from(&config);
let spawner = PtySpawner::with_config(pty_config);
let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let handle = spawner.spawn(command, &args_owned).await?;
let async_pty = WindowsAsyncPty::from_handle(handle);
let mut session = Session::new(async_pty, config);
session.state = SessionState::Running;
Ok(session)
}
#[must_use]
pub fn pid(&self) -> u32 {
if let Ok(transport) = self.transport.try_lock() {
transport.pid()
} else {
0
}
}
pub async fn resize_pty(&mut self, cols: u16, rows: u16) -> Result<()> {
let mut transport = self.transport.lock().await;
transport.resize(cols, rows)
}
#[must_use]
pub fn is_running(&self) -> bool {
if let Ok(transport) = self.transport.try_lock() {
transport.is_running()
} else {
true }
}
pub fn kill(&self) -> Result<()> {
if let Ok(mut transport) = self.transport.try_lock() {
transport.kill()
} else {
Err(ExpectError::io_context(
"killing process",
std::io::Error::new(std::io::ErrorKind::WouldBlock, "transport is locked"),
))
}
}
}
pub trait SessionExt {
fn send_expect(
&mut self,
send: &str,
expect: impl Into<Pattern>,
) -> impl std::future::Future<Output = Result<Match>> + Send;
fn resize(
&mut self,
dimensions: Dimensions,
) -> impl std::future::Future<Output = Result<()>> + Send;
}
fn is_pty_eof_error(e: &std::io::Error) -> bool {
use std::io::ErrorKind;
if e.kind() == ErrorKind::BrokenPipe {
return true;
}
#[cfg(unix)]
{
if let Some(errno) = e.raw_os_error() {
if errno == libc::EIO {
return true;
}
}
}
false
}
#[cfg(all(test, feature = "mock"))]
mod ambient_pattern_tests {
use std::time::Duration;
use super::Session;
use crate::config::SessionConfig;
use crate::expect::{HandlerAction, Pattern, PersistentPattern};
use crate::mock::MockTransport;
fn session_with(output: &str) -> (Session<MockTransport>, MockTransport) {
let transport = MockTransport::new();
let handle = transport.clone();
handle.queue_output_str(output);
let session = Session::new(transport, SessionConfig::default());
(session, handle)
}
#[tokio::test]
async fn after_pattern_fires_as_fallback() {
let (mut session, handle) = session_with("Show more? ");
let after = PersistentPattern::with_response(Pattern::literal("more? "), "yes\n");
session.pattern_manager_mut().add_after(after);
let _ = session
.expect_timeout(Pattern::literal("NEVER"), Duration::from_millis(150))
.await;
let sent = String::from_utf8_lossy(&handle.take_input()).into_owned();
assert!(
sent.contains("yes"),
"after-pattern should have responded; sent = {sent:?}"
);
}
#[tokio::test]
async fn before_respond_consumes_trigger() {
let (mut session, handle) = session_with("password: welcome\n");
let before = PersistentPattern::with_response(Pattern::literal("password:"), "secret\n");
session.pattern_manager_mut().add_before(before);
let m = session
.expect_timeout(Pattern::literal("welcome"), Duration::from_secs(2))
.await
.expect("welcome should match");
assert!(
!m.before.contains("password"),
"before-trigger was not consumed; before = {:?}",
m.before
);
let sent = String::from_utf8_lossy(&handle.take_input()).into_owned();
assert!(
sent.contains("secret"),
"responder should have sent; sent = {sent:?}"
);
}
#[tokio::test]
async fn before_return_consumes_across_calls() {
let (mut session, _handle) = session_with("prompt data\n");
let before = PersistentPattern::new(
Pattern::literal("prompt"),
Box::new(|_| HandlerAction::Return("HANDLED".into())),
);
session.pattern_manager_mut().add_before(before);
let first = session
.expect_timeout(Pattern::literal("data"), Duration::from_secs(2))
.await
.expect("first expect");
assert_eq!(first.matched, "HANDLED", "before Return should fire first");
let second = session
.expect_timeout(Pattern::literal("data"), Duration::from_secs(2))
.await
.expect("second expect should match data, not re-trigger");
assert!(
second.matched.contains("data"),
"before Return re-triggered across calls; got {:?}",
second.matched
);
}
#[tokio::test]
async fn before_beats_explicit_pattern() {
let (mut session, _handle) = session_with("xy\n");
let before = PersistentPattern::new(
Pattern::literal("x"),
Box::new(|_| HandlerAction::Return("BEFORE".into())),
);
session.pattern_manager_mut().add_before(before);
let m = session
.expect_timeout(Pattern::literal("x"), Duration::from_secs(2))
.await
.expect("match");
assert_eq!(
m.matched, "BEFORE",
"before should win over the explicit pattern"
);
}
#[tokio::test]
async fn explicit_beats_after_pattern() {
let (mut session, _handle) = session_with("target\n");
let after = PersistentPattern::new(
Pattern::literal("target"),
Box::new(|_| HandlerAction::Return("AFTER".into())),
);
session.pattern_manager_mut().add_after(after);
let m = session
.expect_timeout(Pattern::literal("target"), Duration::from_secs(2))
.await
.expect("match");
assert_eq!(
m.matched, "target",
"explicit pattern should suppress the after-pattern"
);
}
#[tokio::test]
async fn after_return_consumes_across_calls() {
let (mut session, _handle) = session_with("prompt data\n");
let after = PersistentPattern::new(
Pattern::literal("prompt"),
Box::new(|_| HandlerAction::Return("A_HANDLED".into())),
);
session.pattern_manager_mut().add_after(after);
let first = session
.expect_timeout(Pattern::literal("NOPE"), Duration::from_secs(2))
.await
.expect("after-pattern should fire as fallback");
assert_eq!(first.matched, "A_HANDLED");
let second = session
.expect_timeout(Pattern::literal("data"), Duration::from_secs(2))
.await
.expect("second expect should match data, not re-trigger");
assert!(
second.matched.contains("data"),
"after Return re-triggered across calls; got {:?}",
second.matched
);
}
}