feat(gui): tines-gui skeleton (ipc client, config gen, theme)
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
//! The GUI's generated configs must deserialize as the service's `Config`
|
||||
//! byte-for-byte: unknown keys are fatal on the service side.
|
||||
|
||||
use tines_gui::config::{NodeConfig, generate_node_id};
|
||||
|
||||
#[test]
|
||||
fn generated_config_parses_as_service_config() {
|
||||
// bt: transport bt, a peer, no bus socket.
|
||||
let bt = NodeConfig::bt(
|
||||
generate_node_id(),
|
||||
Some(generate_node_id()),
|
||||
"/tmp/fleet/log".into(),
|
||||
"/tmp/fleet/node.sock".into(),
|
||||
);
|
||||
let json = serde_json::to_string(&bt).unwrap();
|
||||
let parsed: tines::config::Config =
|
||||
serde_json::from_str(&json).expect("bt config must parse as the service config");
|
||||
assert_eq!(parsed.node_id, bt.node_id);
|
||||
assert_eq!(parsed.peer, bt.peer);
|
||||
assert_eq!(parsed.bus_socket, None);
|
||||
assert_eq!(parsed.validate(), Ok(()));
|
||||
|
||||
// bus: transport bus, no peer, a bus socket.
|
||||
let bus = NodeConfig::bus(
|
||||
generate_node_id(),
|
||||
None,
|
||||
"/tmp/fleet/log".into(),
|
||||
"/tmp/fleet/node.sock".into(),
|
||||
"/tmp/fleet/bus.sock".into(),
|
||||
);
|
||||
let json = serde_json::to_string(&bus).unwrap();
|
||||
let parsed: tines::config::Config =
|
||||
serde_json::from_str(&json).expect("bus config must parse as the service config");
|
||||
assert_eq!(parsed.node_id, bus.node_id);
|
||||
assert_eq!(parsed.peer, None);
|
||||
assert_eq!(parsed.bus_socket, Some("/tmp/fleet/bus.sock".to_string()));
|
||||
assert_eq!(parsed.validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_id_is_32_hex_chars() {
|
||||
let id = generate_node_id();
|
||||
assert_eq!(id.len(), 32, "node id must be 32 chars, got {id:?}");
|
||||
assert!(
|
||||
id.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
|
||||
"node id must be lowercase hex, got {id:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_dir_is_0700() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
// Point XDG_STATE_HOME at a scratch dir so the test never touches the
|
||||
// real ~/.local/state, and fleet_dir's creation is what we inspect.
|
||||
let scratch = std::env::temp_dir().join(format!("tines-gui-state-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&scratch).ok();
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
// SAFETY: this is the only test in this binary touching fleet_dir, and
|
||||
// the test binary dies right after; the env change cannot leak to other
|
||||
// tests in other binaries.
|
||||
unsafe { std::env::set_var("XDG_STATE_HOME", &scratch) };
|
||||
|
||||
let dir = tines_gui::config::fleet_dir("fleet-a");
|
||||
let meta = std::fs::metadata(&dir).expect("fleet dir must be created");
|
||||
assert!(meta.is_dir());
|
||||
assert_eq!(
|
||||
meta.permissions().mode() & 0o777,
|
||||
0o700,
|
||||
"fleet dir must be 0700"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&scratch).ok();
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The IPC client against a fake service speaking the real wire framing
|
||||
//! (`[u32 LE len][JSON]`), from a test-local UnixListener.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tines::{Request, Response};
|
||||
use tines_gui::ipc_client::IpcClient;
|
||||
|
||||
fn scratch_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tines-gui-ipc-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn read_frame(stream: &mut UnixStream) -> Vec<u8> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).unwrap();
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
let mut buf = vec![0u8; len];
|
||||
stream.read_exact(&mut buf).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
fn write_frame(stream: &mut UnixStream, body: &[u8]) {
|
||||
let len = (body.len() as u32).to_le_bytes();
|
||||
stream.write_all(&len).unwrap();
|
||||
stream.write_all(body).unwrap();
|
||||
stream.flush().unwrap();
|
||||
}
|
||||
|
||||
/// Answer one Observe request with an Observe response, then end the
|
||||
/// connection (EOF).
|
||||
fn answer_once(socket: &Path) {
|
||||
// A restarted server finds the previous server's socket path still on
|
||||
// disk (Linux leaves the file behind when a bound listener drops), so
|
||||
// it removes the stale path first — exactly what a real service does.
|
||||
let _ = std::fs::remove_file(socket);
|
||||
let (mut stream, _) = UnixListener::bind(socket)
|
||||
.unwrap()
|
||||
.accept()
|
||||
.expect("server accepts");
|
||||
let body = read_frame(&mut stream);
|
||||
// Request-agnostic: the client-side tests only assert the id pairing,
|
||||
// so any well-formed request gets an Observe-shaped response.
|
||||
let _: Request = serde_json::from_slice(&body).expect("well-formed request");
|
||||
write_frame(
|
||||
&mut stream,
|
||||
&serde_json::to_vec(&Response::Observe { id: "00".into() }).unwrap(),
|
||||
);
|
||||
// Dropping the stream here closes the connection: the client sees EOF.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_roundtrips_observe() {
|
||||
let dir = scratch_dir("rt");
|
||||
let socket = dir.join("s.sock");
|
||||
|
||||
let server_socket = socket.clone();
|
||||
let server = thread::spawn(move || answer_once(&server_socket));
|
||||
|
||||
let client = IpcClient::spawn(socket.clone()).expect("client spawns");
|
||||
let id = client
|
||||
.request(Request::Observe {
|
||||
topic: "ab".into(),
|
||||
payload: "cd".into(),
|
||||
to: None,
|
||||
lifetime_ms: None,
|
||||
})
|
||||
.expect("request queues");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some((got_id, resp)) = client.try_recv() {
|
||||
assert_eq!(got_id, id, "response must carry the request id");
|
||||
assert!(
|
||||
matches!(resp, Response::Observe { .. }),
|
||||
"expected Observe response, got {resp:?}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "no response arrived");
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_survives_server_restart() {
|
||||
let dir = scratch_dir("restart");
|
||||
let socket = dir.join("s.sock");
|
||||
|
||||
// First server: one round trip, then it goes away.
|
||||
let server_socket = socket.clone();
|
||||
let first = thread::spawn(move || answer_once(&server_socket));
|
||||
|
||||
let client = IpcClient::spawn(socket.clone()).expect("client spawns");
|
||||
let first_id = client
|
||||
.request(Request::Events)
|
||||
.expect("first request queues");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some((got, _)) = client.try_recv() {
|
||||
assert_eq!(got, first_id);
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "no response to first request");
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
first.join().unwrap();
|
||||
|
||||
// Restart: a fresh listener on the same path answers the next request.
|
||||
client.reconnect();
|
||||
let server_socket = socket.clone();
|
||||
let second = thread::spawn(move || answer_once(&server_socket));
|
||||
|
||||
let second_id = client
|
||||
.request(Request::Events)
|
||||
.expect("second request queues");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some((got, _)) = client.try_recv() {
|
||||
assert_eq!(got, second_id, "ids keep increasing across restarts");
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "no response after restart");
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
second.join().unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user