#![cfg(all(windows, feature = "tokio"))]
pub mod helpers;
use std::io;
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::{Duration, Instant};
use conpty_oxide::tokio::Command;
use conpty_oxide::{ErrorKind, ExitStatus, SessionOptions, Size};
use helpers::tokio_support::{within, Session};
use helpers::{expected_size, reported_size, watchdog};
const BUDGET: Duration = Duration::from_secs(40);
const DEADLINE: Duration = Duration::from_secs(30);
const ANSWER: Duration = Duration::from_secs(15);
const CONCURRENT_ROUNDS: u16 = 12;
const CLOSE_RACE_BUDGET: Duration = Duration::from_secs(10);
fn size(rows: u16, cols: u16) -> Size {
Size::try_new(cols, rows).expect("test dimensions must be valid")
}
const ESC: u8 = 0x1b;
async fn run_cmd(args: &[&str]) -> (String, ExitStatus) {
Session::start(Command::new("cmd.exe").args(args))
.finish()
.await
}
#[tokio::test]
async fn echoed_text_comes_back_with_a_successful_status() {
let _watchdog = watchdog(BUDGET);
within("echoed_text_comes_back", DEADLINE, async {
const MARKER: &str = "conpty-oxide-async-basic-echo";
let (output, status) = run_cmd(&["/c", "echo", MARKER]).await;
assert!(
output.contains(MARKER),
"the echoed marker is missing from the rendered output: {output:?}"
);
assert!(status.success(), "unexpected status: {status}");
assert_eq!(status.code(), 0);
})
.await;
}
#[tokio::test]
async fn a_nonzero_exit_code_is_reported_verbatim() {
let _watchdog = watchdog(BUDGET);
within(
"a_nonzero_exit_code_is_reported_verbatim",
DEADLINE,
async {
let (_output, status) = run_cmd(&["/c", "exit", "42"]).await;
assert_eq!(status.code(), 42);
assert!(!status.success());
assert_ne!(status.code(), 259);
},
)
.await;
}
#[tokio::test]
async fn the_output_is_a_virtual_terminal_stream() {
let _watchdog = watchdog(BUDGET);
within("the_output_is_a_virtual_terminal_stream", DEADLINE, async {
let (bytes, status) = Session::start(Command::new("cmd.exe").args(["/c", "echo", "vt"]))
.finish_raw()
.await;
assert!(
bytes.contains(&ESC),
"no escape sequences in the output, so this was not a pseudoconsole: {:?}",
String::from_utf8_lossy(&bytes)
);
assert!(status.success());
})
.await;
}
#[tokio::test]
async fn an_interactive_session_ends_when_the_shell_exits() {
let _watchdog = watchdog(BUDGET);
within("an_interactive_session", DEADLINE, async {
let mut session = Session::start(
Command::new("cmd.exe")
.current_dir(env!("CARGO_MANIFEST_DIR"))
.env_remove("DIRCMD"),
);
session.output.wait_for(">", ANSWER).await;
session.write_line("dir");
session.output.wait_for("Cargo.toml", ANSWER).await;
session.write_line("exit");
let (output, status) = session.finish().await;
assert!(status.success(), "unexpected status: {status}");
assert!(
output.contains("Cargo.toml"),
"the directory listing is missing from the collected output: {output:?}"
);
})
.await;
}
#[tokio::test]
async fn the_child_observes_a_resize() {
let _watchdog = watchdog(BUDGET);
within("the_child_observes_a_resize", DEADLINE, async {
let initial = size(24, 80);
let resized = size(30, 100);
let mut session = Session::start_with(
&mut Command::new("cmd.exe"),
SessionOptions::new().size(initial),
);
session.output.wait_for(">", ANSWER).await;
session.write_line("mode con");
session
.output
.wait_until_rendered("the session's initial size", ANSWER, |text| {
reported_size(text) == Some(expected_size(initial))
})
.await;
session
.controller
.resize(resized)
.expect("resizing a live session must succeed");
assert_eq!(session.controller.size(), resized);
session.write_line("mode con");
session
.output
.wait_until_rendered("the resized dimensions", ANSWER, |text| {
reported_size(text) == Some(expected_size(resized))
})
.await;
session.write_line("exit");
let (_output, status) = session.finish().await;
assert!(status.success(), "unexpected status: {status}");
})
.await;
}
#[tokio::test]
async fn concurrent_resizes_keep_the_controller_and_child_in_sync() {
let _watchdog = watchdog(BUDGET);
within(
"concurrent_resizes_keep_the_controller_and_child_in_sync",
DEADLINE,
async {
let initial = size(18, 70);
let mut session = Session::start_with(
&mut Command::new("cmd.exe"),
SessionOptions::new().size(initial),
);
session.output.wait_for(">", ANSWER).await;
for round in 0..CONCURRENT_ROUNDS {
let first = size(20 + round * 2, 80 + round * 2);
let second = size(21 + round * 2, 81 + round * 2);
let gate = Arc::new(Barrier::new(3));
let first_controller = session.controller.clone();
let first_gate = Arc::clone(&gate);
let first_resize = thread::spawn(move || {
first_gate.wait();
first_controller.resize(first)
});
let second_controller = session.controller.clone();
let second_gate = Arc::clone(&gate);
let second_resize = thread::spawn(move || {
second_gate.wait();
second_controller.resize(second)
});
gate.wait();
first_resize
.join()
.expect("the first resize thread must not panic")
.expect("the first concurrent resize must succeed");
second_resize
.join()
.expect("the second resize thread must not panic")
.expect("the second concurrent resize must succeed");
let recorded = session.controller.size();
assert!(
recorded == first || recorded == second,
"the controller recorded a size no caller submitted: {recorded}"
);
session.write_line("mode con");
session
.output
.wait_until_rendered("one of the concurrent sizes", ANSWER, |text| {
let observed = reported_size(text);
observed == Some(expected_size(first))
|| observed == Some(expected_size(second))
})
.await;
assert_eq!(
reported_size(&session.output.text()),
Some(expected_size(recorded)),
"controller.size() diverged from the child after concurrent resizes"
);
}
session.write_line("exit");
let (_output, status) = session.finish().await;
assert!(status.success(), "unexpected status: {status}");
},
)
.await;
}
#[tokio::test]
async fn resize_racing_session_close_transitions_to_not_connected() {
let _watchdog = watchdog(BUDGET);
within(
"resize_racing_session_close_transitions_to_not_connected",
DEADLINE,
async {
let session = Session::start(Command::new("cmd.exe").args([
"/d",
"/c",
"ping",
"-n",
"3",
"127.0.0.1",
]));
let Session {
mut child,
output,
input,
controller,
} = session;
let racer = thread::spawn(move || {
let deadline = Instant::now() + CLOSE_RACE_BUDGET;
let mut next = size(24, 80);
loop {
match controller.resize(next) {
Ok(()) => {
next = if next == size(24, 80) {
size(25, 81)
} else {
size(24, 80)
};
},
Err(err) if err.kind() == ErrorKind::Resize => {
return err
.io_error()
.expect("resize failures retain their I/O error")
.kind();
},
Err(other) => panic!("unexpected resize failure during close: {other}"),
}
assert!(
Instant::now() < deadline,
"resize never observed the closing session"
);
thread::sleep(Duration::from_millis(2));
}
});
let status = child
.wait()
.await
.expect("waiting for the probe must succeed");
assert!(status.success(), "unexpected status: {status}");
output.join().await;
assert_eq!(
racer.join().expect("the resize racer must not panic"),
io::ErrorKind::NotConnected
);
input.close().await;
},
)
.await;
}
#[tokio::test]
async fn clearing_agrees_with_the_capability_query() {
let _watchdog = watchdog(BUDGET);
within(
"clearing_agrees_with_the_capability_query",
DEADLINE,
async {
const BEFORE: &str = "conpty-oxide-async-before-clear";
const AFTER: &str = "conpty-oxide-async-after-clear";
let mut session = Session::start(&mut Command::new("cmd.exe"));
let supported = session.controller.supports_clear();
session.output.wait_for(">", ANSWER).await;
session.write_line(&format!("echo {BEFORE}"));
session.output.wait_for(BEFORE, ANSWER).await;
match session.controller.clear() {
Ok(()) => assert!(
supported,
"clear succeeded on a backend that reports no clear support"
),
Err(err) if err.kind() == ErrorKind::UnsupportedFeature => {
assert!(
!supported,
"clear was refused as unsupported on a backend that reports \
clear support"
);
assert!(err.to_string().contains("ClearPseudoConsole"));
},
Err(other) => panic!("clearing the console failed: {other}"),
}
session.write_line(&format!("echo {AFTER}"));
session.output.wait_for(AFTER, ANSWER).await;
session.write_line("exit");
let (_output, status) = session.finish().await;
assert!(status.success(), "unexpected status: {status}");
},
)
.await;
}