feat(gui): launcher window (create fleet, node controls, actions)

This commit is contained in:
2026-10-25 00:36:41 -07:00
parent 2be008e4b0
commit 97ea5f8f12
9 changed files with 1208 additions and 49 deletions
+38
View File
@@ -0,0 +1,38 @@
//! Shared test setup for the integration suites. Compiled per test binary;
//! only declare `mod common;` in tests that use these helpers.
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::OnceLock;
/// 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.
pub fn env_with_fake_busd() -> &'static PathBuf {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("tines-gui-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Fake tines-busd: create the socket named by argv[1], then stay
// alive so the spawning Fleet keeps a live child.
let fake = dir.join("tines-busd");
std::fs::write(&fake, "#!/bin/sh\ntouch \"$1\"\nsleep 60\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = format!(
"{}:{}",
dir.display(),
std::env::var("PATH").unwrap_or_default()
);
// SAFETY: set once, before any fleet_dir call; this process is a
// test binary that exits afterwards, so nothing else observes it.
unsafe {
std::env::set_var("XDG_STATE_HOME", &dir);
std::env::set_var("PATH", path);
}
dir
})
}
+10 -42
View File
@@ -1,48 +1,16 @@
//! Fleet layout and node-config tests. The bus daemon is a fake `tines-busd`
//! seeded onto PATH (the real binary isn't on PATH for unit tests): it
//! records its argv and creates the socket file, so the fleet's spawn
//! protocol is what's under test — the real daemon is covered by tines-bus's
//! own suite and Task 4's e2e.
//! seeded onto PATH (see `common`; the real binary isn't on PATH for unit
//! tests): it creates the socket file named by argv[1], so the fleet's
//! spawn protocol is what's under test — the real daemon is covered by
//! tines-bus's own suite and Task 4's e2e.
mod common;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use tines_gui::fleet::{Fleet, Mode};
/// One shared scratch setup for every test in this binary: a temp dir that
/// becomes both XDG_STATE_HOME (fleet dirs) and a PATH dir holding the fake
/// `tines-busd`. Env is process-global, so it is set exactly once before any
/// test touches fleet_dir.
fn setup() -> &'static PathBuf {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("tines-gui-fleet-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Fake tines-busd: touch the socket named by argv[1], then stay alive.
let fake = dir.join("tines-busd");
std::fs::write(&fake, "#!/bin/sh\ntouch \"$1\"\nsleep 60\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = format!(
"{}:{}",
dir.display(),
std::env::var("PATH").unwrap_or_default()
);
// SAFETY: set once, before any fleet_dir call; this process is a
// test binary that exits afterwards, so nothing else observes it.
unsafe {
std::env::set_var("XDG_STATE_HOME", &dir);
std::env::set_var("PATH", path);
}
dir
})
}
fn fleet_name(tag: &str) -> String {
format!(
"test-{tag}-{}",
@@ -52,7 +20,7 @@ fn fleet_name(tag: &str) -> String {
#[test]
fn fleet_dir_layout() {
setup();
common::env_with_fake_busd();
let fleet = Fleet::create(&fleet_name("layout")).expect("fleet creates");
let dir = tines_gui::config::fleet_dir(&fleet_name("layout"));
let meta = std::fs::metadata(&dir).expect("fleet dir exists");
@@ -75,7 +43,7 @@ fn fleet_dir_layout() {
#[test]
fn add_node_bus_config_mounts_data() {
setup();
common::env_with_fake_busd();
let fleet_name = fleet_name("bus");
let fleet = Fleet::create(&fleet_name).expect("fleet creates");
let spec = fleet
@@ -95,7 +63,7 @@ fn add_node_bus_config_mounts_data() {
#[test]
fn add_node_bt_config_has_peer() {
setup();
common::env_with_fake_busd();
let fleet_name = fleet_name("bt");
let fleet = Fleet::create(&fleet_name).expect("fleet creates");
let peer = "22".repeat(16);
@@ -114,7 +82,7 @@ fn add_node_bt_config_has_peer() {
#[test]
fn generated_id_roundtrips() {
setup();
common::env_with_fake_busd();
let fleet_name = fleet_name("gen");
let fleet = Fleet::create(&fleet_name).expect("fleet creates");
let spec = fleet
+108
View File
@@ -0,0 +1,108 @@
//! Launcher UI tests (egui_kittest, headless). No real docker or daemon:
//! the fake `tines-busd` is seeded on PATH (see `common`), and the
//! containers are injected directly into the launcher's state.
mod common;
use egui::accesskit;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use tines_gui::config::fleet_dir;
use tines_gui::docker::ContainerId;
use tines_gui::fleet::Mode;
use tines_gui::launcher::{ContainerEntry, Launcher, LauncherAction};
/// The test-side action drain: peek at what the UI queued (main.rs owns the
/// real drain with the subprocess spawns).
fn drain(launcher: &mut Launcher) -> Vec<LauncherAction> {
launcher.actions.drain(..).collect()
}
#[test]
fn create_fleet_action_queues_spawn() {
common::env_with_fake_busd();
let mut harness = Harness::new_eframe(|_cc| Launcher::new());
harness.run();
// Type the fleet name into the first text field, then click Create.
harness
.get_all_by_role(accesskit::Role::TextInput)
.next()
.expect("fleet name field")
.focus();
harness.run();
harness
.get_all_by_role(accesskit::Role::TextInput)
.next()
.expect("fleet name field")
.type_text("yard");
harness.run();
harness.get_by_label("Create").click();
harness.run();
let launcher = harness.state();
assert!(
launcher.fleet.is_some(),
"Create must set the fleet (spawning the bus daemon)"
);
// The action queue must be drainable; fleet creation itself is not an
// action (it is the fleet field).
let launcher = harness.state_mut();
assert!(drain(launcher).is_empty());
}
#[test]
fn open_window_queues_spawn() {
common::env_with_fake_busd();
let mut harness = Harness::new_eframe(|_cc| {
let mut launcher = Launcher::new();
launcher.fleet_name = "yard".to_string();
launcher.containers.push(ContainerEntry {
name: "alpha".to_string(),
id: ContainerId("deadbeef".to_string()),
mode: Mode::Sim,
});
launcher
});
harness.run();
harness.get_by_label("Open").click();
harness.run();
let actions = drain(harness.state_mut());
assert_eq!(
actions,
vec![LauncherAction::OpenWindow {
name: "alpha".to_string(),
dir: fleet_dir("yard").join("alpha"),
id: ContainerId("deadbeef".to_string()),
mode: Mode::Sim,
}]
);
}
#[test]
fn stop_queues_stop_action() {
common::env_with_fake_busd();
let mut harness = Harness::new_eframe(|_cc| {
let mut launcher = Launcher::new();
launcher.containers.push(ContainerEntry {
name: "bravo".to_string(),
id: ContainerId("cafebabe".to_string()),
mode: Mode::Bt,
});
launcher
});
harness.run();
harness.get_by_label("Stop").click();
harness.run();
let actions = drain(harness.state_mut());
assert_eq!(
actions,
vec![LauncherAction::StopContainer(ContainerId(
"cafebabe".to_string()
))]
);
}