feat: add interactive WIP terminal client
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::tempdir;
|
||||
use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer};
|
||||
use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState};
|
||||
use wip_tui::config::Config;
|
||||
use wip_tui::runtime::{HttpTransport, Runtime, default_wire_limits};
|
||||
|
||||
#[test]
|
||||
fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() {
|
||||
let temporary = tempdir().expect("temporary root");
|
||||
let outside = tempdir().expect("outside root");
|
||||
std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file");
|
||||
std::fs::create_dir(temporary.path().join("nested")).expect("create nested directory");
|
||||
std::fs::write(outside.path().join("secret.txt"), b"outside-secret").expect("outside file");
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(outside.path(), temporary.path().join("outside-link"))
|
||||
.expect("create symlink");
|
||||
|
||||
let mut server_config = ServerConfig::for_root(temporary.path());
|
||||
server_config.access = AccessMode::ReadWrite;
|
||||
server_config.transport_security = TransportSecurity::DevelopmentHttp;
|
||||
let bound = Arc::new(
|
||||
WipFsServer::new(server_config)
|
||||
.expect("build filesystem server")
|
||||
.bind()
|
||||
.expect("bind filesystem server"),
|
||||
);
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let worker = {
|
||||
let bound = Arc::clone(&bound);
|
||||
let shutdown = Arc::clone(&shutdown);
|
||||
thread::spawn(move || bound.serve_until(shutdown))
|
||||
};
|
||||
let endpoint = format!("http://127.0.0.1:{}/wip", bound.local_addr().port());
|
||||
let config = Config::parse(
|
||||
[endpoint, "--allow-http".into()],
|
||||
None,
|
||||
Some("filesystem-test".into()),
|
||||
|_| None,
|
||||
)
|
||||
.expect("development config");
|
||||
let transport = HttpTransport::new(&config, default_wire_limits().max_response_bytes())
|
||||
.expect("HTTP transport");
|
||||
let mut runtime = Runtime::new(&config, transport).expect("runtime");
|
||||
|
||||
runtime.connect();
|
||||
settle(&mut runtime);
|
||||
assert_eq!(runtime.app().selected_path, "/");
|
||||
assert!(runtime.app().entries.contains_key("/hello.txt"));
|
||||
assert!(runtime.app().entries.contains_key("/nested"));
|
||||
assert!(runtime.app().entries.contains_key("/outside-link"));
|
||||
|
||||
// Keyboard-select the file and load its read interface and descriptor.
|
||||
runtime.app_mut().selected_path = "/hello.txt".into();
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
runtime.app_mut().operation_cursor = 0;
|
||||
assert!(!runtime.handle_key(InputKey::Enter));
|
||||
settle(&mut runtime);
|
||||
let read_reference = runtime.app().entries["/hello.txt"]
|
||||
.object
|
||||
.as_ref()
|
||||
.expect("file object")
|
||||
.interfaces[0]
|
||||
.clone();
|
||||
assert_eq!(
|
||||
runtime.app().interfaces[&read_reference]
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.expect("read descriptor")
|
||||
.operations[0]
|
||||
.name,
|
||||
"read"
|
||||
);
|
||||
|
||||
// Descriptor-driven read editor, explicit confirmation, and execution.
|
||||
runtime.app_mut().operation_cursor = 1;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app().modal, Modal::Editor(_)));
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app().modal, Modal::Confirm { .. }));
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(runtime.app().history[0].kind, HistoryKind::Success);
|
||||
assert!(
|
||||
runtime.app().history[0]
|
||||
.detail
|
||||
.contains("68656c6c6f20776f726c64")
|
||||
);
|
||||
|
||||
// Calls conservatively stale the object even for a read operation; refresh
|
||||
// before constructing the separately confirmed write call.
|
||||
runtime.app_mut().focus = Focus::Tree;
|
||||
runtime.handle_key(InputKey::Refresh);
|
||||
settle(&mut runtime);
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
|
||||
// Load the separately-qualified write interface and replace with the
|
||||
// automatically attached observed validator.
|
||||
runtime.app_mut().operation_cursor = 2;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
settle(&mut runtime);
|
||||
runtime.app_mut().operation_cursor = 3;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
set_only_field(&mut runtime, "dXBkYXRlZA=="); // "updated"
|
||||
confirm_and_execute(&mut runtime);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(
|
||||
std::fs::read(temporary.path().join("hello.txt")).expect("updated file"),
|
||||
b"updated"
|
||||
);
|
||||
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Stale);
|
||||
|
||||
// Reobserve, prepare a replacement from that observation, then mutate the
|
||||
// file externally before explicit execution. The stale validator is rejected,
|
||||
// the Entry becomes stale, and no automatic call is created.
|
||||
runtime.app_mut().focus = Focus::Tree;
|
||||
runtime.handle_key(InputKey::Refresh);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh);
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
runtime.app_mut().operation_cursor = 3;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
set_only_field(&mut runtime, "c3RhbGUtd3JpdGU=");
|
||||
runtime.handle_key(InputKey::Enter); // confirmation fixes user input only
|
||||
std::fs::write(temporary.path().join("hello.txt"), b"external").expect("external change");
|
||||
let history_before = runtime.app().history.len();
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(
|
||||
std::fs::read(temporary.path().join("hello.txt")).expect("externally changed file"),
|
||||
b"external"
|
||||
);
|
||||
assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Stale);
|
||||
assert!(runtime.app().in_flight.is_empty());
|
||||
assert_eq!(runtime.app().history.len(), history_before + 1);
|
||||
assert_eq!(runtime.app().history[0].kind, HistoryKind::DomainFailure);
|
||||
assert!(
|
||||
runtime.app().history[0]
|
||||
.detail
|
||||
.contains("no automatic retry")
|
||||
);
|
||||
|
||||
// Refresh enables a new, separately confirmed execution.
|
||||
runtime.app_mut().focus = Focus::Tree;
|
||||
runtime.handle_key(InputKey::Refresh);
|
||||
settle(&mut runtime);
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
runtime.app_mut().operation_cursor = 3;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
set_only_field(&mut runtime, "ZmluYWw=");
|
||||
confirm_and_execute(&mut runtime);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(
|
||||
std::fs::read(temporary.path().join("hello.txt")).expect("final file"),
|
||||
b"final"
|
||||
);
|
||||
|
||||
// Symlink inspection exposes the link payload only. It never traverses or
|
||||
// materializes the target directory outside the configured root.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
runtime.app_mut().selected_path = "/outside-link".into();
|
||||
runtime.app_mut().focus = Focus::Operations;
|
||||
runtime.app_mut().operation_cursor = 0;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
settle(&mut runtime);
|
||||
runtime.app_mut().operation_cursor = 1;
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
settle(&mut runtime);
|
||||
assert_eq!(runtime.app().history[0].kind, HistoryKind::Success);
|
||||
assert!(!runtime.app().history[0].detail.contains("outside-secret"));
|
||||
assert!(
|
||||
!runtime
|
||||
.app()
|
||||
.entries
|
||||
.contains_key("/outside-link/secret.txt")
|
||||
);
|
||||
}
|
||||
|
||||
shutdown.store(true, Ordering::Release);
|
||||
worker
|
||||
.join()
|
||||
.expect("join filesystem listener")
|
||||
.expect("filesystem listener exits");
|
||||
}
|
||||
|
||||
fn settle(runtime: &mut Runtime<HttpTransport>) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !runtime.app().in_flight.is_empty() {
|
||||
assert!(Instant::now() < deadline, "runtime requests did not settle");
|
||||
runtime.wait_and_poll(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn set_only_field(runtime: &mut Runtime<HttpTransport>, input: &str) {
|
||||
let Modal::Editor(editor) = &mut runtime.app_mut().modal else {
|
||||
panic!("operation editor was not open")
|
||||
};
|
||||
assert_eq!(editor.fields.len(), 1);
|
||||
editor.fields[0].input = input.into();
|
||||
}
|
||||
|
||||
fn confirm_and_execute(runtime: &mut Runtime<HttpTransport>) {
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
assert!(matches!(runtime.app().modal, Modal::Confirm { .. }));
|
||||
runtime.handle_key(InputKey::Enter);
|
||||
}
|
||||
Reference in New Issue
Block a user