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); +}