feat: add interactive WIP terminal client
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::tempdir;
|
||||
use wip_fs_server::{ServerConfig, WipFsServer};
|
||||
use wip_https::encode_protocol_error_response;
|
||||
use wip_https::http::{Request, Response};
|
||||
use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction};
|
||||
use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState};
|
||||
use wip_tui::config::Config;
|
||||
use wip_tui::runtime::{
|
||||
Runtime, Transport, TransportError, TransportErrorKind, default_wire_limits,
|
||||
};
|
||||
|
||||
struct ScriptedTransport {
|
||||
server: Arc<WipFsServer>,
|
||||
sequence: Arc<AtomicUsize>,
|
||||
delayed_after_startup: bool,
|
||||
unknown_calls: bool,
|
||||
fail_call_prepare: bool,
|
||||
panic_calls: bool,
|
||||
}
|
||||
|
||||
impl ScriptedTransport {
|
||||
fn new(server: Arc<WipFsServer>) -> Self {
|
||||
Self {
|
||||
server,
|
||||
sequence: Arc::new(AtomicUsize::new(0)),
|
||||
delayed_after_startup: false,
|
||||
unknown_calls: false,
|
||||
fail_call_prepare: false,
|
||||
panic_calls: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for ScriptedTransport {
|
||||
type Prepared = Request<Vec<u8>>;
|
||||
|
||||
fn prepare(&self, request: Request<Vec<u8>>) -> Result<Self::Prepared, TransportError> {
|
||||
if self.fail_call_prepare && request.uri().path().ends_with("/v1/call_operation") {
|
||||
return Err(TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "scripted local conversion failure".into(),
|
||||
});
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError> {
|
||||
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel);
|
||||
if self.delayed_after_startup && sequence > 0 {
|
||||
// Each refresh starts object then tree. Make the first pair lose to
|
||||
// the second pair to exercise exact RequestId ownership.
|
||||
let delay = if matches!(sequence, 1 | 2) { 80 } else { 5 };
|
||||
thread::sleep(Duration::from_millis(delay));
|
||||
}
|
||||
if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") {
|
||||
panic!("scripted transport worker panic");
|
||||
}
|
||||
if self.unknown_calls && request.uri().path().ends_with("/v1/call_operation") {
|
||||
return Ok(encode_protocol_error_response(
|
||||
ProtocolInteraction::CallOperation,
|
||||
&ProtocolError {
|
||||
code: ProtocolErrorCode::OperationOutcomeUnknown,
|
||||
message: "scripted commit uncertainty".into(),
|
||||
},
|
||||
default_wire_limits(),
|
||||
)
|
||||
.expect("encode unknown outcome"));
|
||||
}
|
||||
Ok(self.server.handle_http(request))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_order_refresh_and_cancel_keep_request_identity_safe() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
std::fs::write(temporary.path().join("file"), b"contents").expect("file");
|
||||
let server = Arc::new(
|
||||
WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"),
|
||||
);
|
||||
let mut transport = ScriptedTransport::new(server);
|
||||
transport.delayed_after_startup = true;
|
||||
let config = config();
|
||||
let mut runtime = Runtime::new(&config, transport).expect("runtime");
|
||||
runtime.connect();
|
||||
settle(&mut runtime);
|
||||
|
||||
runtime.app_mut().focus = Focus::Tree;
|
||||
runtime.handle_key(InputKey::Refresh);
|
||||
runtime.handle_key(InputKey::Refresh);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(runtime.app().entries["/"].state, ViewState::Fresh);
|
||||
assert!(runtime.app().history.iter().any(|item| {
|
||||
item.kind == HistoryKind::Information && item.title.contains("stale response")
|
||||
}));
|
||||
|
||||
runtime.handle_key(InputKey::Refresh);
|
||||
runtime.handle_key(InputKey::CancelRead);
|
||||
runtime.handle_key(InputKey::CancelRead);
|
||||
settle(&mut runtime);
|
||||
assert!(runtime.app().in_flight.is_empty());
|
||||
assert!(
|
||||
runtime
|
||||
.app()
|
||||
.history
|
||||
.iter()
|
||||
.any(|item| item.title == "retrieval canceled")
|
||||
);
|
||||
|
||||
// When a refreshed tree omits a formerly selected Entry, the client marks
|
||||
// it stale and the presentation no longer exposes old metadata/operations
|
||||
// as current.
|
||||
std::fs::remove_file(temporary.path().join("file")).expect("remove file");
|
||||
runtime.app_mut().selected_path = "/".into();
|
||||
runtime.handle_key(InputKey::Refresh);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(runtime.app().entries["/file"].state, ViewState::Stale);
|
||||
runtime.app_mut().selected_path = "/file".into();
|
||||
assert!(runtime.app().operation_rows().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_transport_preparation_failure_is_definitely_not_dispatched() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
std::fs::write(temporary.path().join("file"), b"contents").expect("file");
|
||||
let server = Arc::new(
|
||||
WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"),
|
||||
);
|
||||
let mut transport = ScriptedTransport::new(server);
|
||||
transport.fail_call_prepare = true;
|
||||
let execute_calls = Arc::clone(&transport.sequence);
|
||||
let config = config();
|
||||
let mut runtime = Runtime::new(&config, transport).expect("runtime");
|
||||
runtime.connect();
|
||||
settle(&mut runtime);
|
||||
|
||||
open_read_editor(&mut runtime);
|
||||
let calls_before_operation = execute_calls.load(Ordering::Acquire);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert_eq!(
|
||||
execute_calls.load(Ordering::Acquire),
|
||||
calls_before_operation,
|
||||
"preparation failure must not invoke transport I/O"
|
||||
);
|
||||
assert!(runtime.app().in_flight.is_empty());
|
||||
assert_eq!(runtime.app().history[0].kind, HistoryKind::TransportFailure);
|
||||
assert!(runtime.app().history[0].detail.contains("not dispatched"));
|
||||
assert_eq!(runtime.app().entries["/file"].state, ViewState::Fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_panic_settles_as_unknown_instead_of_stranding_the_main_loop() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
std::fs::write(temporary.path().join("file"), b"contents").expect("file");
|
||||
let server = Arc::new(
|
||||
WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"),
|
||||
);
|
||||
let mut transport = ScriptedTransport::new(server);
|
||||
transport.panic_calls = true;
|
||||
let config = config();
|
||||
let mut runtime = Runtime::new(&config, transport).expect("runtime");
|
||||
runtime.connect();
|
||||
settle(&mut runtime);
|
||||
|
||||
open_read_editor(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
settle(&mut runtime);
|
||||
assert!(runtime.app().in_flight.is_empty());
|
||||
assert_eq!(runtime.app().history[0].kind, HistoryKind::OutcomeUnknown);
|
||||
assert!(
|
||||
runtime.app().history[0]
|
||||
.detail
|
||||
.contains("transport worker failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_dispatch_unknown_is_prominent_stale_and_never_retried() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
std::fs::write(temporary.path().join("file"), b"contents").expect("file");
|
||||
let server = Arc::new(
|
||||
WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"),
|
||||
);
|
||||
let mut transport = ScriptedTransport::new(server);
|
||||
transport.unknown_calls = true;
|
||||
let config = config();
|
||||
let mut runtime = Runtime::new(&config, transport).expect("runtime");
|
||||
runtime.connect();
|
||||
settle(&mut runtime);
|
||||
|
||||
open_read_editor(&mut runtime);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
settle(&mut runtime);
|
||||
|
||||
assert_eq!(runtime.app().history[0].kind, HistoryKind::OutcomeUnknown);
|
||||
assert!(
|
||||
runtime.app().history[0]
|
||||
.detail
|
||||
.contains("side effects are unknown")
|
||||
);
|
||||
assert_eq!(runtime.app().entries["/file"].state, ViewState::Stale);
|
||||
assert!(runtime.app().in_flight.is_empty());
|
||||
let history = runtime.app().history.len();
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
runtime.poll();
|
||||
assert_eq!(
|
||||
runtime.app().history.len(),
|
||||
history,
|
||||
"no retry was manufactured"
|
||||
);
|
||||
}
|
||||
|
||||
fn config() -> Config {
|
||||
Config::parse(
|
||||
["https://example.test/wip"],
|
||||
None,
|
||||
Some("scripted-test".into()),
|
||||
|_| None,
|
||||
)
|
||||
.expect("config")
|
||||
}
|
||||
|
||||
fn open_read_editor(runtime: &mut Runtime<ScriptedTransport>) {
|
||||
runtime.app_mut().selected_path = "/file".into();
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
runtime.app_mut().operation_cursor = 0;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
settle(runtime);
|
||||
runtime.app_mut().operation_cursor = 1;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app().modal, Modal::Editor(_)));
|
||||
}
|
||||
|
||||
fn settle(runtime: &mut Runtime<ScriptedTransport>) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !runtime.app().in_flight.is_empty() {
|
||||
assert!(Instant::now() < deadline, "scripted runtime did not settle");
|
||||
runtime.wait_and_poll(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user