diff --git a/tines-gui/src/lib.rs b/tines-gui/src/lib.rs
index 227f677e..c4cb588f 100644
--- a/tines-gui/src/lib.rs
+++ b/tines-gui/src/lib.rs
@@ -13,3 +13,4 @@ pub mod fleet;
pub mod ipc_client;
pub mod launcher;
pub mod theme;
+pub mod window;
diff --git a/tines-gui/src/main.rs b/tines-gui/src/main.rs
index a2c5f298..2609f811 100644
--- a/tines-gui/src/main.rs
+++ b/tines-gui/src/main.rs
@@ -1,15 +1,25 @@
//! tines-gui entry point: no args opens the launcher window;
-//! `--node
` opens a node window (Task 8b).
+//! `--node ` opens a node window.
use eframe::egui;
+use tines_gui::docker::ContainerId;
+use tines_gui::fleet::Mode;
+use tines_gui::launcher::Launcher;
+use tines_gui::window::{NodeAction, NodeWin};
fn main() -> Result<(), Box> {
let args: Vec = std::env::args().collect();
match args.as_slice() {
[_, flag, dir, id, mode] if flag == "--node" => {
- let _ = (dir, id, mode);
- eprintln!("tines-gui: node window is Task 8b — not wired yet");
- std::process::exit(1);
+ let mode = match mode.as_str() {
+ "bt" => Mode::Bt,
+ "sim" => Mode::Sim,
+ other => {
+ eprintln!("tines-gui: unknown mode {other:?} (want bt|sim)");
+ std::process::exit(2);
+ }
+ };
+ run_node_window(dir.into(), ContainerId(id.clone()), mode)?;
}
[_] => run_launcher()?,
_ => {
@@ -23,7 +33,7 @@ fn main() -> Result<(), Box> {
/// The launcher window: renders via `Launcher::update`, then drains the
/// action queue — the only place subprocess spawns happen.
struct LauncherApp {
- launcher: tines_gui::launcher::Launcher,
+ launcher: Launcher,
theme: tines_gui::theme::OmarchyTheme,
watching: bool,
}
@@ -44,25 +54,90 @@ impl eframe::App for LauncherApp {
}
}
-fn run_launcher() -> Result<(), Box> {
+/// The node window: renders via `NodeWin::update`, then drains the
+/// container actions (stop/remove/logs).
+struct NodeWinApp {
+ node: NodeWin,
+ theme: tines_gui::theme::OmarchyTheme,
+ watching: bool,
+}
+
+impl eframe::App for NodeWinApp {
+ fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+ let ctx = ui.ctx().clone();
+ self.theme = tines_gui::theme::OmarchyTheme::load();
+ self.theme.apply(&ctx);
+ if !self.watching {
+ let ctx = ctx.clone();
+ if tines_gui::theme::OmarchyTheme::watch(move || ctx.request_repaint()).is_ok() {
+ self.watching = true;
+ }
+ }
+ self.node.update(&ctx);
+ while let Some(action) = self.node.actions.pop_front() {
+ match action {
+ NodeAction::Stop => {
+ if let Err(e) = self.node.docker.stop(&self.node.container_id) {
+ self.node.error = Some(format!("cannot stop: {e}"));
+ }
+ }
+ NodeAction::Remove => {
+ if let Err(e) = self.node.docker.remove(&self.node.container_id) {
+ self.node.error = Some(format!("cannot remove: {e}"));
+ }
+ }
+ NodeAction::FetchLogs => match self.node.docker.logs(&self.node.container_id, 50) {
+ Ok(tail) => self.node.apply_log_tail(tail),
+ Err(e) => self.node.error = Some(format!("cannot fetch logs: {e}")),
+ },
+ }
+ }
+ }
+}
+
+fn options(title: &str, size: [f32; 2]) -> eframe::NativeOptions {
let viewport = egui::ViewportBuilder::default()
.with_app_id("tines-gui")
- .with_title("tines — fleet console")
- .with_inner_size([760.0, 520.0]);
- let options = eframe::NativeOptions {
+ .with_title(title)
+ .with_inner_size(size);
+ eframe::NativeOptions {
viewport,
glow_options: eframe::egui_glow::GlowConfiguration {
vsync: false,
..Default::default()
},
..Default::default()
- };
+ }
+}
+
+fn run_launcher() -> Result<(), Box> {
eframe::run_native(
"tines-gui",
- options,
+ options("tines — fleet console", [760.0, 520.0]),
Box::new(|_cc| {
Ok(Box::new(LauncherApp {
- launcher: tines_gui::launcher::Launcher::new(),
+ launcher: Launcher::new(),
+ theme: tines_gui::theme::OmarchyTheme::load(),
+ watching: false,
+ }))
+ }),
+ )
+ .map_err(|e| format!("eframe: {e}").into())
+}
+
+fn run_node_window(
+ dir: std::path::PathBuf,
+ container_id: ContainerId,
+ mode: Mode,
+) -> Result<(), Box> {
+ let node = NodeWin::new(dir, container_id, mode);
+ let title = format!("tines — {}", node.name);
+ eframe::run_native(
+ "tines-gui-node",
+ options(&title, [680.0, 480.0]),
+ Box::new(move |_cc| {
+ Ok(Box::new(NodeWinApp {
+ node,
theme: tines_gui::theme::OmarchyTheme::load(),
watching: false,
}))
diff --git a/tines-gui/src/window.rs b/tines-gui/src/window.rs
new file mode 100644
index 00000000..324fa67b
--- /dev/null
+++ b/tines-gui/src/window.rs
@@ -0,0 +1,437 @@
+//! The node window: the whole service control surface for one container.
+//!
+//! All node I/O goes through the [`IpcClient`] — [`NodeWin::update`] queues
+//! requests (Status/List once, Events every 500 ms, Observe/Belief on
+//! clicks) and drains responses each frame; the UI thread never blocks.
+//! Container operations (stop/remove/logs) are queued as [`NodeAction`]s
+//! and drained by `main.rs`, like the launcher's action queue.
+
+use std::collections::VecDeque;
+use std::path::PathBuf;
+
+use egui::Context;
+use tines::{BeliefOut, ObservationOut, Request, Response, TransportKind};
+
+use crate::docker::{ContainerId, Docker};
+use crate::fleet::Mode;
+use crate::ipc_client::IpcClient;
+
+/// The status strip's data, from `Response::Status`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StatusData {
+ /// Hex of the node's own `NodeId`.
+ pub id: String,
+ /// Next tines sequence number the node will use.
+ pub next_seq: u64,
+ /// Number of observations in the store.
+ pub stored: u32,
+}
+
+/// One events-feed row.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum FeedEntry {
+ /// A peer joined or was lost.
+ Contact {
+ /// Hex of the peer's `NodeId`.
+ peer: String,
+ /// `true` for a new contact, `false` for a loss.
+ joined: bool,
+ },
+ /// A confirmed delivery.
+ Delivered {
+ /// Hex of the peer the observation reached.
+ peer: String,
+ /// Hex of the delivered observation's id.
+ id: String,
+ },
+ /// Counted drops drained from the exchange.
+ Dropped {
+ /// Hex of the peer the dropped frame came from, or `None` for the
+ /// overflow slot.
+ peer: Option,
+ /// The drop reason, debug-formatted.
+ reason: String,
+ /// The number of frames folded into the counter.
+ count: u32,
+ },
+}
+
+/// An intent the container panel queued; `main.rs` drains these.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum NodeAction {
+ /// `docker stop` the container.
+ Stop,
+ /// `docker rm` the container.
+ Remove,
+ /// Re-fetch the log tail.
+ FetchLogs,
+}
+
+/// One node window's full state.
+pub struct NodeWin {
+ /// The node's IPC client (dialed at the node's service socket).
+ pub client: IpcClient,
+ /// The node name (the config dir's leaf).
+ pub name: String,
+ /// How the node connects to its peers.
+ pub mode: Mode,
+ /// The transport the service is configured for, derived from `mode`.
+ pub transport: TransportKind,
+ /// The latest `Status` answer.
+ pub status: Option,
+ /// Latest beliefs, most recent first.
+ pub beliefs: VecDeque,
+ /// The events feed, oldest first.
+ pub feed: VecDeque,
+ /// The store browser's rows, from the latest `List`.
+ pub store_rows: VecDeque,
+ /// The id of the most recently inserted observation.
+ pub last_id: Option,
+
+ /// The docker frontend (container panel).
+ pub docker: Docker,
+ /// The container this window controls.
+ pub container_id: ContainerId,
+ /// Intents queued by [`NodeWin::update`], drained by `main.rs`.
+ pub actions: VecDeque,
+
+ /// The observe panel's topic field.
+ pub topic: String,
+ /// The observe panel's payload field.
+ pub payload: String,
+ /// The observe panel's recipient field (hex-encoded on send).
+ pub recipient: String,
+ /// The observe panel's lifetime field (ms).
+ pub lifetime: String,
+ /// When on, the topic/payload/recipient inputs are already hex.
+ pub hex_input: bool,
+ /// The belief panel's topic field.
+ pub belief_topic: String,
+ /// The container's last-fetched log tail.
+ pub log_tail: Option,
+ /// The latest error to surface.
+ pub error: Option,
+
+ /// Outstanding requests (responses not yet drained).
+ pub outstanding: usize,
+ /// When the Events poll last fired (input time).
+ pub last_events_poll: f64,
+ /// Whether the initial Status request went out.
+ status_requested: bool,
+ /// Whether the initial List request went out.
+ list_requested: bool,
+}
+
+/// Encode text as lowercase hex (the service's wire form for topics,
+/// payloads, and node ids).
+fn to_hex(text: &str) -> String {
+ let mut out = String::with_capacity(text.len() * 2);
+ for byte in text.as_bytes() {
+ out.push_str(&format!("{byte:02x}"));
+ }
+ out
+}
+
+impl NodeWin {
+ /// Build the window: dial the service at `/sock`, name from the
+ /// dir's leaf, transport from the mode.
+ pub fn new(dir: PathBuf, container_id: ContainerId, mode: Mode) -> NodeWin {
+ let name = dir
+ .file_name()
+ .map(|n| n.to_string_lossy().to_string())
+ .unwrap_or_else(|| "node".to_string());
+ let transport = match mode {
+ Mode::Bt => TransportKind::Bt,
+ Mode::Sim => TransportKind::Bus,
+ };
+ let client = IpcClient::spawn(dir.join("sock")).expect("ipc worker spawns");
+ NodeWin {
+ client,
+ name,
+ mode,
+ transport,
+ status: None,
+ beliefs: VecDeque::new(),
+ feed: VecDeque::new(),
+ store_rows: VecDeque::new(),
+ last_id: None,
+ docker: Docker,
+ container_id,
+ actions: VecDeque::new(),
+ topic: String::new(),
+ payload: String::new(),
+ recipient: String::new(),
+ lifetime: String::new(),
+ hex_input: false,
+ belief_topic: String::new(),
+ log_tail: None,
+ error: None,
+ outstanding: 0,
+ last_events_poll: f64::NEG_INFINITY,
+ status_requested: false,
+ list_requested: false,
+ }
+ }
+
+ /// Queue a request and count it outstanding.
+ fn send(&mut self, req: Request) {
+ match self.client.request(req) {
+ Ok(_) => self.outstanding += 1,
+ Err(e) => self.error = Some(e.to_string()),
+ }
+ }
+
+ /// Apply one drained response.
+ fn apply(&mut self, resp: Response) {
+ match resp {
+ Response::Status {
+ id,
+ next_seq,
+ stored,
+ } => {
+ self.status = Some(StatusData {
+ id,
+ next_seq,
+ stored,
+ });
+ }
+ Response::List {
+ observations,
+ truncated: _,
+ } => self.store_rows = observations.into(),
+ Response::Belief { obs } => {
+ if let Some(obs) = obs {
+ self.beliefs.push_front(obs);
+ }
+ }
+ Response::Observe { id } => self.last_id = Some(id),
+ Response::Events {
+ events,
+ contacts,
+ drops,
+ } => {
+ for contact in contacts {
+ self.feed.push_back(FeedEntry::Contact {
+ peer: contact.peer,
+ joined: contact.joined,
+ });
+ }
+ for event in events {
+ self.feed.push_back(FeedEntry::Delivered {
+ peer: event.peer,
+ id: event.id,
+ });
+ }
+ for drop in drops {
+ self.feed.push_back(FeedEntry::Dropped {
+ peer: drop.peer,
+ reason: drop.reason,
+ count: drop.count,
+ });
+ }
+ }
+ Response::Error { message } => self.error = Some(message),
+ }
+ }
+
+ /// Render the node window and queue intents. Side effects are limited
+ /// to queueing requests on the [`IpcClient`] (responses drain here
+ /// too); container intents wait in `actions` for `main.rs`.
+ pub fn update(&mut self, ctx: &Context) {
+ while let Some((_id, resp)) = self.client.try_recv() {
+ self.outstanding = self.outstanding.saturating_sub(1);
+ self.apply(resp);
+ }
+
+ if !self.status_requested {
+ self.status_requested = true;
+ self.send(Request::Status);
+ }
+ if !self.list_requested {
+ self.list_requested = true;
+ self.send(Request::List);
+ }
+ let now = ctx.input(|i| i.time);
+ if self.last_events_poll.is_nan() || now - self.last_events_poll >= 0.5 {
+ self.last_events_poll = now;
+ self.send(Request::Events);
+ }
+
+ // egui 0.36 is Ui-based: rebuild the root Ui, like the launcher.
+ let mut ui = egui::Ui::new(
+ ctx.clone(),
+ egui::Id::new("tines-node-root"),
+ egui::UiBuilder::new().max_rect(ctx.content_rect()),
+ );
+
+ egui::Panel::top("strip").show(&mut ui, |ui| {
+ let (id, next_seq, stored) = match &self.status {
+ Some(s) => (s.id.clone(), s.next_seq.to_string(), s.stored.to_string()),
+ None => ("—".to_string(), "—".to_string(), "—".to_string()),
+ };
+ ui.horizontal(|ui| {
+ ui.label(format!(
+ "node {id} · seq {next_seq} · stored {stored} · transport {}",
+ match self.transport {
+ TransportKind::Bt => "bt",
+ TransportKind::Bus => "bus",
+ }
+ ));
+ if self.mode == Mode::Bt {
+ ui.separator();
+ ui.label("bt: single peer — addressed observes beyond the configured peer have no route");
+ }
+ });
+ });
+
+ egui::Panel::left("controls")
+ .resizable(true)
+ .show(&mut ui, |ui| {
+ ui.heading("Observe");
+ ui.label("topic:");
+ ui.text_edit_singleline(&mut self.topic);
+ ui.label("payload:");
+ ui.text_edit_singleline(&mut self.payload);
+ ui.label("recipient (optional):");
+ ui.text_edit_singleline(&mut self.recipient);
+ ui.label("lifetime ms (optional):");
+ ui.text_edit_singleline(&mut self.lifetime);
+ ui.checkbox(&mut self.hex_input, "input is hex");
+ if ui.button("Send").clicked() {
+ let encode = |s: &str| {
+ if self.hex_input {
+ s.to_string()
+ } else {
+ to_hex(s)
+ }
+ };
+ let to = (!self.recipient.is_empty()).then(|| encode(&self.recipient));
+ let lifetime_ms = (!self.lifetime.is_empty())
+ .then(|| self.lifetime.trim().parse::().ok())
+ .flatten();
+ self.send(Request::Observe {
+ topic: encode(&self.topic),
+ payload: encode(&self.payload),
+ to,
+ lifetime_ms,
+ });
+ }
+ if let Some(id) = &self.last_id {
+ ui.label(format!("last: {id}"));
+ }
+
+ ui.separator();
+ ui.heading("Belief");
+ ui.label("topic:");
+ ui.text_edit_singleline(&mut self.belief_topic);
+ if ui.button("Fetch").clicked() {
+ let topic = if self.hex_input {
+ self.belief_topic.clone()
+ } else {
+ to_hex(&self.belief_topic)
+ };
+ self.send(Request::Belief { topic });
+ }
+ for belief in &self.beliefs {
+ ui.label(format!("{}: {}", belief.topic, belief.payload));
+ }
+
+ ui.separator();
+ ui.heading("Container");
+ ui.horizontal(|ui| {
+ if ui.button("Refresh logs").clicked() {
+ self.actions.push_back(NodeAction::FetchLogs);
+ }
+ if ui.button("Stop").clicked() {
+ self.actions.push_back(NodeAction::Stop);
+ }
+ if ui.button("Remove").clicked() {
+ self.actions.push_back(NodeAction::Remove);
+ }
+ });
+ if let Some(tail) = &self.log_tail {
+ egui::ScrollArea::vertical()
+ .max_height(120.0)
+ .auto_shrink([false, false])
+ .show(ui, |ui| {
+ ui.monospace(tail);
+ });
+ }
+ });
+
+ egui::CentralPanel::default().show(&mut ui, |ui| {
+ ui.heading("Store");
+ if ui.button("Refresh").clicked() {
+ self.send(Request::List);
+ }
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ egui::Grid::new("store").striped(true).show(ui, |ui| {
+ ui.label("id");
+ ui.label("topic");
+ ui.label("payload");
+ ui.label("age ms");
+ ui.end_row();
+ for obs in &self.store_rows {
+ ui.monospace(&obs.id);
+ ui.monospace(&obs.topic);
+ ui.monospace(&obs.payload);
+ ui.label(obs.age_ms.to_string());
+ ui.end_row();
+ }
+ });
+ });
+ });
+
+ egui::Panel::right("feed")
+ .resizable(true)
+ .show(&mut ui, |ui| {
+ ui.heading("Events");
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ for entry in &self.feed {
+ match entry {
+ FeedEntry::Contact { peer, joined } => {
+ ui.label(format!(
+ "contact {peer} {}",
+ if *joined { "joined" } else { "lost" }
+ ));
+ }
+ FeedEntry::Delivered { peer, id } => {
+ ui.label(format!("delivered {id} to {peer}"));
+ }
+ FeedEntry::Dropped {
+ peer,
+ reason,
+ count,
+ } => {
+ let peer = peer.as_deref().unwrap_or("overflow");
+ ui.label(format!("dropped {count} from {peer}: {reason}"));
+ }
+ }
+ }
+ });
+ });
+
+ if let Some(error) = &self.error {
+ egui::Panel::bottom("error").show(&mut ui, |ui| {
+ ui.colored_label(egui::Color32::from_rgb(0xff, 0x55, 0x55), error);
+ });
+ }
+
+ // Keep frames flowing while responses are in flight (the harness
+ // settles when this stops).
+ if self.outstanding > 0 {
+ ctx.request_repaint();
+ }
+ }
+
+ /// The container panel's latest log tail (set by `main.rs`'s drain).
+ pub fn apply_log_tail(&mut self, tail: String) {
+ self.log_tail = Some(tail);
+ }
+}
+
+impl eframe::App for NodeWin {
+ fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+ NodeWin::update(self, ui.ctx());
+ }
+}
diff --git a/tines-gui/tests/common/mod.rs b/tines-gui/tests/common/mod.rs
index 33a56b8b..446d38a7 100644
--- a/tines-gui/tests/common/mod.rs
+++ b/tines-gui/tests/common/mod.rs
@@ -5,10 +5,13 @@ use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::OnceLock;
+use tines::{Request, Response};
+
/// One shared scratch setup per test binary: a temp dir that becomes both
/// XDG_STATE_HOME (fleet dirs) and a PATH dir holding a fake `tines-busd`.
/// Env is process-global, so it is set exactly once before any test touches
/// `fleet_dir` — a per-test `set_var` from parallel test threads would race.
+#[allow(dead_code)]
pub fn env_with_fake_busd() -> &'static PathBuf {
static DIR: OnceLock = OnceLock::new();
DIR.get_or_init(|| {
@@ -36,3 +39,124 @@ pub fn env_with_fake_busd() -> &'static PathBuf {
dir
})
}
+
+/// A fake `tines` service speaking the real wire framing
+/// (`[u32 LE len][JSON]`), answering every request through a handler
+/// closure and logging every request it receives. Serving runs on a
+/// thread; dropping the service shuts it down.
+#[allow(dead_code)]
+pub struct FakeService {
+ /// The socket the service is bound to (what the client dials).
+ pub socket: PathBuf,
+ requests: std::sync::Arc>>,
+ shutdown: std::sync::Arc,
+ listener: Option,
+ handle: Option>,
+}
+
+#[allow(dead_code)]
+impl FakeService {
+ /// Bind `socket` and serve requests forever (until dropped).
+ pub fn start(socket: PathBuf, handler: F) -> FakeService
+ where
+ F: Fn(&Request) -> Response + Send + Sync + 'static,
+ {
+ use std::io::{Read, Write};
+ use std::os::unix::net::UnixListener;
+
+ if let Some(parent) = socket.parent() {
+ std::fs::create_dir_all(parent).unwrap();
+ }
+ use std::time::Duration;
+ // A restarted service finds the previous one's socket path still on
+ // disk (Linux leaves the file behind), so remove the stale path
+ // first — exactly what a real service does.
+ let _ = std::fs::remove_file(&socket);
+ let listener = UnixListener::bind(&socket).unwrap();
+ listener.set_nonblocking(true).unwrap();
+
+ let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+ let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+
+ let serve_listener = listener.try_clone().unwrap();
+ let serve_requests = std::sync::Arc::clone(&requests);
+ let serve_shutdown = std::sync::Arc::clone(&shutdown);
+ let handle = std::thread::spawn(move || {
+ while !serve_shutdown.load(std::sync::atomic::Ordering::Relaxed) {
+ match serve_listener.accept() {
+ Ok((mut stream, _)) => {
+ // Timeout idle reads so `Drop`'s join can always
+ // finish: the serve thread would otherwise block in
+ // `read_exact` on a connection the client keeps
+ // open with nothing in flight.
+ let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
+ // Serve this connection until it closes.
+ loop {
+ let mut len_buf = [0u8; 4];
+ if stream.read_exact(&mut len_buf).is_err() {
+ break;
+ }
+ let len = u32::from_le_bytes(len_buf) as usize;
+ let mut buf = vec![0u8; len];
+ if stream.read_exact(&mut buf).is_err() {
+ break;
+ }
+ let req: Request = match serde_json::from_slice(&buf) {
+ Ok(req) => req,
+ Err(e) => {
+ let body = serde_json::to_vec(&Response::Error {
+ message: format!("unparseable: {e}"),
+ })
+ .unwrap();
+ let len = (body.len() as u32).to_le_bytes();
+ let _ = stream.write_all(&len);
+ let _ = stream.write_all(&body);
+ continue;
+ }
+ };
+ let body = serde_json::to_vec(&handler(&req)).unwrap();
+ serve_requests.lock().unwrap().push(req);
+ let len = (body.len() as u32).to_le_bytes();
+ if stream.write_all(&len).is_err() || stream.write_all(&body).is_err() {
+ break;
+ }
+ let _ = stream.flush();
+ }
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
+ // Keep the poll tight: the harness can burn dozens
+ // of frames in the time one 10ms sleep takes, and
+ // the client's round trip must land within the
+ // step budget.
+ std::thread::sleep(std::time::Duration::from_millis(1));
+ }
+ Err(_) => break,
+ }
+ }
+ });
+
+ FakeService {
+ socket,
+ requests,
+ shutdown,
+ listener: Some(listener),
+ handle: Some(handle),
+ }
+ }
+
+ /// True when any received request satisfies the predicate.
+ pub fn any_request(&self, pred: impl Fn(&Request) -> bool) -> bool {
+ self.requests.lock().unwrap().iter().any(pred)
+ }
+}
+
+impl Drop for FakeService {
+ fn drop(&mut self) {
+ self.shutdown
+ .store(true, std::sync::atomic::Ordering::Relaxed);
+ self.listener.take();
+ if let Some(handle) = self.handle.take() {
+ let _ = handle.join();
+ }
+ }
+}
diff --git a/tines-gui/tests/window.rs b/tines-gui/tests/window.rs
new file mode 100644
index 00000000..992473ec
--- /dev/null
+++ b/tines-gui/tests/window.rs
@@ -0,0 +1,220 @@
+//! Node window tests (egui_kittest, headless). The fake service speaks the
+//! real wire framing and answers scripted responses; the window is driven
+//! through `NodeWin::update` and asserted via the harness tree and the
+//! fake's received-request log.
+
+mod common;
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::{Duration, Instant};
+
+use egui::accesskit;
+use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
+use tines::{ContactOut, Request, Response};
+use tines_gui::docker::ContainerId;
+use tines_gui::fleet::Mode;
+use tines_gui::window::NodeWin;
+
+/// One scratch dir per test: the fake service binds `/sock`, which is
+/// exactly where the node window dials.
+fn scratch_dir(tag: &str) -> PathBuf {
+ std::env::temp_dir().join(format!(
+ "tines-gui-window-{tag}-{}-{}",
+ std::process::id(),
+ COUNTER.fetch_add(1, Ordering::Relaxed)
+ ))
+}
+
+static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
+
+fn new_window(dir: &std::path::Path) -> NodeWin {
+ NodeWin::new(
+ dir.to_path_buf(),
+ ContainerId("deadbeef".to_string()),
+ Mode::Sim,
+ )
+}
+
+/// The harness: generous max steps so async IPC round trips can land
+/// (the default 4 panics on any slow dial).
+fn harness_for(dir: &std::path::Path) -> Harness<'static, NodeWin> {
+ let dir = dir.to_path_buf();
+ Harness::builder()
+ .with_max_steps(400)
+ .build_eframe(move |_cc| new_window(&dir))
+}
+
+/// A service that answers Status/List/Belief/Observe and returns one
+/// contact from the first Events drain.
+fn default_handler() -> impl Fn(&Request) -> Response + Send + Sync + 'static {
+ let contact_sent = Arc::new(AtomicBool::new(false));
+ move |req: &Request| match req {
+ Request::Status => Response::Status {
+ id: "0a0b".to_string(),
+ next_seq: 7,
+ stored: 3,
+ },
+ Request::Events => {
+ if contact_sent.swap(true, Ordering::Relaxed) {
+ Response::Events {
+ events: vec![],
+ contacts: vec![],
+ drops: vec![],
+ }
+ } else {
+ Response::Events {
+ events: vec![],
+ contacts: vec![ContactOut {
+ peer: "bb".to_string(),
+ joined: true,
+ }],
+ drops: vec![],
+ }
+ }
+ }
+ Request::List => Response::List {
+ observations: vec![],
+ truncated: false,
+ },
+ Request::Belief { .. } => Response::Belief { obs: None },
+ Request::Observe { .. } => Response::Observe {
+ id: "00".to_string(),
+ },
+ }
+}
+
+/// The response drain is a background worker: poll the fake's log until
+/// the predicate holds or the deadline passes.
+fn wait_for bool>(fake: &common::FakeService, pred: F) {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ loop {
+ if fake.any_request(&pred) {
+ return;
+ }
+ assert!(Instant::now() < deadline, "request never arrived");
+ std::thread::sleep(Duration::from_millis(20));
+ }
+}
+
+/// Focus the `idx`-th text input and type `text` into it. The iterator
+/// borrows the harness, so it must drop before each `run()`.
+fn type_into(harness: &mut Harness<'_, NodeWin>, idx: usize, text: &str) {
+ {
+ let mut inputs = harness.get_all_by_role(accesskit::Role::TextInput);
+ inputs.nth(idx).unwrap().focus();
+ }
+ settle(harness);
+ {
+ let mut inputs = harness.get_all_by_role(accesskit::Role::TextInput);
+ inputs.nth(idx).unwrap().type_text(text);
+ }
+ settle(harness);
+}
+
+/// Step the app until every outstanding request has been answered (real
+/// time between steps: the IPC round trip runs on a background thread, and
+/// a tight `run()` loop burns hundreds of frames before the first response
+/// lands — while the 500ms events poll keeps queueing, so `run()` never
+/// settles and panics at max_steps).
+fn settle(harness: &mut Harness<'_, NodeWin>) {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ loop {
+ harness.step();
+ if harness.state().outstanding == 0 {
+ return;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "service never answered the outstanding requests"
+ );
+ std::thread::sleep(Duration::from_millis(5));
+ }
+}
+
+#[test]
+fn status_strip_shows_node_id() {
+ let dir = scratch_dir("status");
+ let fake = common::FakeService::start(dir.join("sock"), default_handler());
+ let mut harness = harness_for(&dir);
+ settle(&mut harness);
+
+ // The strip is one label; the node id hex must appear in it.
+ assert!(
+ harness.get_all_by_label_contains("0a0b").count() >= 1,
+ "status strip must show the node id answered by Status"
+ );
+ drop(fake);
+}
+
+#[test]
+fn observe_button_queues_observe() {
+ let dir = scratch_dir("observe");
+ let fake = common::FakeService::start(dir.join("sock"), default_handler());
+ let mut harness = harness_for(&dir);
+ settle(&mut harness);
+
+ // Text inputs render in panel order: topic, payload, recipient,
+ // lifetime. Focus-then-type lands text in each field.
+ type_into(&mut harness, 0, "hi");
+ type_into(&mut harness, 1, "yo");
+
+ harness.get_by_label("Send").click();
+ settle(&mut harness);
+
+ wait_for(&fake, |req| {
+ matches!(
+ req,
+ Request::Observe {
+ topic,
+ payload,
+ to: None,
+ lifetime_ms: None,
+ } if topic == "6869" && payload == "796f"
+ )
+ });
+}
+
+#[test]
+fn addressed_fields_queue_observe_to() {
+ let dir = scratch_dir("addressed");
+ let fake = common::FakeService::start(dir.join("sock"), default_handler());
+ let mut harness = harness_for(&dir);
+ settle(&mut harness);
+
+ // topic (0), payload (1), recipient (2), lifetime (3).
+ for (idx, text) in [(0usize, "hi"), (1, "yo"), (2, "aa"), (3, "5000")] {
+ type_into(&mut harness, idx, text);
+ }
+
+ harness.get_by_label("Send").click();
+ settle(&mut harness);
+
+ wait_for(&fake, |req| {
+ matches!(
+ req,
+ Request::Observe {
+ topic,
+ payload,
+ to: Some(to),
+ lifetime_ms: Some(5000),
+ } if topic == "6869" && payload == "796f" && to == "6161"
+ )
+ });
+}
+
+#[test]
+fn feed_shows_contact() {
+ let dir = scratch_dir("feed");
+ let fake = common::FakeService::start(dir.join("sock"), default_handler());
+ let mut harness = harness_for(&dir);
+ settle(&mut harness);
+
+ assert!(
+ harness.get_all_by_label_contains("bb").count() >= 1,
+ "the events feed must show the contact's peer hex"
+ );
+ drop(fake);
+}