feat(gui): node window (status strip, observe, belief, store, events, container)

This commit is contained in:
2026-10-25 01:05:37 -07:00
parent 97ea5f8f12
commit 2a917088f7
5 changed files with 869 additions and 12 deletions
+1
View File
@@ -13,3 +13,4 @@ pub mod fleet;
pub mod ipc_client;
pub mod launcher;
pub mod theme;
pub mod window;
+87 -12
View File
@@ -1,15 +1,25 @@
//! tines-gui entry point: no args opens the launcher window;
//! `--node <dir> <container-id> <mode>` opens a node window (Task 8b).
//! `--node <dir> <container-id> <mode>` 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<dyn std::error::Error + Send + Sync>> {
let args: Vec<String> = 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<dyn std::error::Error + Send + Sync>> {
/// 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<dyn std::error::Error + Send + Sync>> {
/// 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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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,
}))
+437
View File
@@ -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<String>,
/// 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<StatusData>,
/// Latest beliefs, most recent first.
pub beliefs: VecDeque<BeliefOut>,
/// The events feed, oldest first.
pub feed: VecDeque<FeedEntry>,
/// The store browser's rows, from the latest `List`.
pub store_rows: VecDeque<ObservationOut>,
/// The id of the most recently inserted observation.
pub last_id: Option<String>,
/// 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<NodeAction>,
/// 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<String>,
/// The latest error to surface.
pub error: Option<String>,
/// 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 `<dir>/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::<u64>().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());
}
}
+124
View File
@@ -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<PathBuf> = 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<std::sync::Mutex<Vec<Request>>>,
shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
listener: Option<std::os::unix::net::UnixListener>,
handle: Option<std::thread::JoinHandle<()>>,
}
#[allow(dead_code)]
impl FakeService {
/// Bind `socket` and serve requests forever (until dropped).
pub fn start<F>(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();
}
}
}
+220
View File
@@ -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 `<dir>/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<F: Fn(&Request) -> 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);
}