midi-harbor/tests/cli_views.rs
2026-09-28 13:59:10 -05:00

534 lines
20 KiB
Rust

//! The binary against a running daemon.
//!
//! `status` called an idle session "listening" and `session list` called the same session
//! "disconnected", which reads as a fault that is not there. And a second `daemon` started
//! beside a running one took its socket over, leaving two daemons with the same ports and
//! sessions and only one of them reachable.
#![allow(
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used
)]
use midi_harbor_core::endpoint::InvitationPolicy;
use midi_harbor_core::paths::Paths;
use midi_harbor_daemon::Daemon;
use midi_harbor_platform::fake::FakeMidiPlatform;
use midi_harbor_platform::midi::MidiPlatform;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
/// Runs the binary against the given socket, returning its exit code and standard output.
fn cli(socket: &Path, arguments: &[&str]) -> (i32, String) {
let output = Command::new(env!("CARGO_BIN_EXE_midi-harbor"))
.arg("--socket")
.arg(socket)
.args(arguments)
.output()
.expect("the binary runs");
(
output.status.code().expect("an exit code"),
String::from_utf8_lossy(&output.stdout).into_owned(),
)
}
/// Returns the directory every daemon in this file keeps its files under.
///
/// One directory, emptied once per run, rather than one per daemon left behind: that grew by
/// three directories and three sockets every run, and a Linux gate machine had a hundred of them.
fn scratch() -> PathBuf {
static EMPTIED: std::sync::Once = std::sync::Once::new();
let root = std::env::temp_dir().join("mh-cli-views");
EMPTIED.call_once(|| {
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::create_dir_all(&root);
});
root
}
/// Starts a daemon serving on a socket of its own.
async fn serving() -> (Arc<Daemon>, PathBuf) {
serving_with(None).await
}
/// Starts a daemon serving on a socket of its own, from the given configuration.
async fn serving_with(config: Option<&str>) -> (Arc<Daemon>, PathBuf) {
static SERVED: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let unique = format!(
"{}-{}",
std::process::id(),
SERVED.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
let root = scratch().join(&unique);
let _ = std::fs::remove_dir_all(&root);
if let Some(config) = config {
let paths = Paths::rooted_at(&root);
std::fs::create_dir_all(paths.config_dir()).unwrap();
std::fs::write(paths.config_file(), config).unwrap();
}
// Short, because a socket path has a length limit well below a temporary directory's.
let socket = scratch().join(format!("{unique}.sock"));
let daemon = Daemon::start(
Paths::rooted_at(&root).with_socket(Some(socket.clone())),
Arc::new(FakeMidiPlatform::new()) as Arc<dyn MidiPlatform>,
)
.await
.expect("a daemon");
let served = Arc::clone(&daemon);
tokio::spawn(async move {
if let Err(error) = midi_harbor_daemon::run(served).await {
panic!("the daemon stopped serving: {error}");
}
});
for _ in 0..100 {
let probe = socket.clone();
let (code, _) = tokio::task::spawn_blocking(move || cli(&probe, &["status"]))
.await
.unwrap();
if code == 0 {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
(daemon, socket)
}
/// Locks that `status` and `session list` give an idle session the same state, "listening".
///
/// `status` called it "listening" and `session list` called it "disconnected", which reads as a
/// fault that is not there. Both are JSON a script may read, so they must agree.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_session_is_described_alike_in_status_and_in_the_session_list() {
let (daemon, socket) = serving().await;
daemon
.create_network_session("Stage", 0, InvitationPolicy::Prompt)
.await
.unwrap();
let (status, listed) = tokio::task::spawn_blocking(move || {
(
cli(&socket, &["status", "--json"]),
cli(&socket, &["session", "list", "--json"]),
)
})
.await
.unwrap();
assert_eq!(
status.0, 0,
"status must succeed against a running daemon: {status:?}"
);
assert_eq!(
listed.0, 0,
"session list must succeed against a running daemon: {listed:?}"
);
let state_in = |json: &str| -> String {
let value: serde_json::Value = serde_json::from_str(json).expect("json");
let entries = value
.as_array()
.or_else(|| value.get("detail").and_then(|e| e.as_array()))
.expect("a list");
entries
.iter()
.find(|entry| entry["name"] == "Stage")
.and_then(|entry| entry["state"].as_str())
.expect("the session and its state")
.to_owned()
};
assert_eq!(
state_in(&listed.1),
state_in(&status.1),
"the two views must not disagree about one session"
);
assert_eq!(
state_in(&listed.1),
"listening",
"a session waiting to be invited is resting, not disconnected"
);
}
/// Locks that a second `daemon` refuses to start beside a running one, and leaves it reachable.
///
/// A second daemon once took the socket over, leaving two daemons with the same ports and
/// sessions and only one of them reachable.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_second_daemon_does_not_start_beside_a_running_one() {
let (_daemon, socket) = serving().await;
let home = scratch().join("second");
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let probe = socket.clone();
let second = tokio::task::spawn_blocking(move || {
Command::new(env!("CARGO_BIN_EXE_midi-harbor"))
.arg("--socket")
.arg(&probe)
.arg("daemon")
// Its own home, so if it did start it would not touch the user's configuration.
.env("HOME", &home)
.env_remove("XDG_CONFIG_HOME")
.output()
.expect("the binary runs")
})
.await
.unwrap();
assert_eq!(
second.status.code(),
Some(1),
"a second daemon must exit with the generic failure: {second:?}"
);
// Refused before opening anything, not by the socket after its ports and sessions were up.
assert!(
String::from_utf8_lossy(&second.stderr).contains("already running"),
"the refusal must say a daemon is already running: {second:?}"
);
let (code, _) = tokio::task::spawn_blocking(move || cli(&socket, &["status"]))
.await
.unwrap();
assert_eq!(code, 0, "the running daemon lost its socket");
}
/// Locks that a session created without `--policy` takes the configuration's default policy.
///
/// The preference was written to every configuration file and never read: a session created
/// without a policy was always set to prompt, whatever the file said.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_session_created_without_a_policy_takes_the_configured_default() {
let (daemon, socket) = serving_with(Some(
"preferences:\n default_invitation_policy: accept_all\n",
))
.await;
let (code, _) =
tokio::task::spawn_blocking(move || cli(&socket, &["session", "create", "Stage"]))
.await
.unwrap();
assert_eq!(code, 0, "creating a session must succeed");
let policy = daemon
.read(|config, _| {
config
.endpoints
.iter()
.find_map(|endpoint| match &endpoint.kind {
midi_harbor_core::endpoint::EndpointKind::NetworkSession(session)
if endpoint.name.as_str() == "Stage" =>
{
Some(session.invitation_policy)
}
_ => None,
})
})
.await;
assert_eq!(
policy,
Some(InvitationPolicy::AcceptAll),
"the session must take the policy the configuration file names"
);
}
/// Locks that `bluetooth advertise` warns on macOS when the name will not be advertised.
///
/// On macOS a name longer than five bytes is dropped from the advertisement without an error,
/// and devices show the computer's own name. The command once reported only success.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_name_that_will_not_be_sent_is_said_so() {
let (_daemon, socket) = serving().await;
let advertise = |name: &'static str| {
let socket = socket.clone();
move || {
let output = Command::new(env!("CARGO_BIN_EXE_midi-harbor"))
.arg("--socket")
.arg(&socket)
.args(["bluetooth", "advertise", "--name", name])
.output()
.expect("the binary runs");
(
output.status.code(),
String::from_utf8_lossy(&output.stdout).into_owned()
+ &String::from_utf8_lossy(&output.stderr),
)
}
};
let (code, long) = tokio::task::spawn_blocking(advertise("Harbor Mac"))
.await
.unwrap();
assert_eq!(code, Some(0), "a long name is still accepted: {long}");
let (code, short) = tokio::task::spawn_blocking(advertise("HM")).await.unwrap();
assert_eq!(code, Some(0), "a short name is accepted: {short}");
let warned = |text: &str| text.contains("does not fit beside the MIDI service");
assert_eq!(
warned(&long),
cfg!(target_os = "macos"),
"a long name must be warned about on macOS and only there: {long}"
);
assert!(
!warned(&short),
"a name that fits must not be warned about: {short}"
);
}
/// Locks that every command under `--json` writes exactly one JSON document, refusals included
/// (FR-039d).
///
/// Commands that change something reported in a sentence, and under `--json` printed it anyway,
/// so a script got text where it asked for JSON.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn every_command_under_json_writes_one_document() {
let (_daemon, socket) = serving().await;
let json = |arguments: &[&str]| {
let mut all = vec!["--json"];
all.extend_from_slice(arguments);
let (code, out) = cli(&socket, &all);
let document: serde_json::Value = serde_json::from_str(&out)
.unwrap_or_else(|error| panic!("{arguments:?} wrote {out:?}: {error}"));
(code, document)
};
let (code, created) = json(&["port", "create", "Scripted"]);
assert_eq!(code, 0, "creating a port must succeed: {created}");
assert_eq!(created["ok"], true, "a success must say ok: {created}");
assert_eq!(
created["result"]["name"], "Scripted",
"the result must name what was created: {created}"
);
assert!(
created["result"]["id"]
.as_str()
.is_some_and(|id| !id.is_empty()),
"the result must carry the new identifier for a script to use: {created}"
);
for arguments in [
&["port", "disable", "Scripted"][..],
&["port", "enable", "Scripted"][..],
&["session", "create", "Scripted Stage"][..],
&["route", "create", "Scripted", "Scripted Stage"][..],
&["port", "rename", "Scripted", "Renamed", "--yes"][..],
&["port", "delete", "Renamed", "--yes"][..],
] {
let (code, document) = json(arguments);
assert_eq!(code, 0, "{arguments:?} must succeed: {document}");
assert_eq!(
document["ok"], true,
"{arguments:?} must report success in its document: {document}"
);
}
// A refusal is a document too, saying so.
let (code, refused) = json(&["route", "create", "Nothing", "Nowhere"]);
assert_ne!(code, 0, "a route between missing endpoints must fail");
assert_eq!(
refused["ok"], false,
"a refusal must say not ok in its document: {refused}"
);
}
/// Locks that `session disable` and `session enable` switch a session in the daemon's
/// configuration (SC-014b).
///
/// The window switches any endpoint on and off, and the command line offered it for ports only.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_session_can_be_switched_off_and_on_from_the_command_line() {
let (daemon, socket) = serving().await;
let stage = daemon
.create_network_session("Switched", 0, InvitationPolicy::Prompt)
.await
.expect("a session");
let (code, _) = cli(&socket, &["session", "disable", "Switched"]);
assert_eq!(code, 0, "disabling a session must succeed");
let enabled = |daemon: Arc<Daemon>| async move {
daemon
.read(|config, _| {
config
.endpoints
.iter()
.find(|endpoint| endpoint.id == stage.id)
.map(|endpoint| endpoint.enabled)
})
.await
};
assert_eq!(
enabled(Arc::clone(&daemon)).await,
Some(false),
"the daemon must hold the session switched off"
);
let (code, _) = cli(&socket, &["session", "enable", "Switched"]);
assert_eq!(code, 0, "enabling a session must succeed");
assert_eq!(
enabled(Arc::clone(&daemon)).await,
Some(true),
"the daemon must hold the session switched on"
);
}
/// Locks that `diagnostics export` carries every setting of the configuration it was run with.
///
/// The report named each endpoint and its state but none of its settings, so a report about a
/// session that would not connect did not say which machine it was calling.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_diagnostic_report_carries_the_whole_configuration() {
let (_daemon, socket) = serving_with(Some(concat!(
"preferences:\n",
" default_invitation_policy: accept_all\n",
"endpoints:\n",
" - name: Stage\n",
" kind: network_session\n",
" local_name: Stage Left\n",
" invitation_policy: accept_known\n",
" peer: 2a7026ac-9b34-4e57-b19b-918962a74e8f\n",
"peers:\n",
" - id: 2a7026ac-9b34-4e57-b19b-918962a74e8f\n",
" name: Studio PC\n",
" addresses:\n",
" - 192.0.2.13:5004\n",
)))
.await;
let (code, written) =
tokio::task::spawn_blocking(move || cli(&socket, &["diagnostics", "export"]))
.await
.unwrap();
assert_eq!(code, 0, "exporting a report must succeed");
let report: serde_json::Value = serde_json::from_str(&written).unwrap();
let configuration = &report["configuration"];
assert_eq!(
configuration["preferences"]["default_invitation_policy"], "accept_all",
"the report must carry the preferences: {configuration}"
);
let stage = configuration["endpoints"]
.as_array()
.unwrap()
.iter()
.find(|endpoint| endpoint["name"] == "Stage")
.unwrap();
assert_eq!(
stage["local_name"], "Stage Left",
"the report must carry the session's advertised name: {stage}"
);
assert_eq!(
stage["invitation_policy"], "accept_known",
"the report must carry the session's own policy: {stage}"
);
assert_eq!(
stage["peer"], "2a7026ac-9b34-4e57-b19b-918962a74e8f",
"the report must say which machine the session calls: {stage}"
);
assert_eq!(
configuration["peers"][0]["addresses"][0], "192.0.2.13:5004",
"the report must carry where that machine was last reached: {configuration}"
);
}
/// Locks that `config import-apple` reads Apple's setup through the daemon, and exits 4 where
/// there is none.
///
/// The command line queried CoreMIDI itself, which Principle II reserves for the daemon.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apples_setup_is_read_through_the_daemon() {
let (_daemon, socket) = serving().await;
let (code, written) =
tokio::task::spawn_blocking(move || cli(&socket, &["config", "import-apple"]))
.await
.unwrap();
if cfg!(target_os = "macos") {
// Without --yes it shows a plan and asks, or has nothing to do.
assert!(
code == 0 || code == 7,
"without --yes the import must show its plan and ask, or finish: exit {code}: {written}"
);
assert!(
written.contains("to create") || written.contains("nothing to import"),
"the import must describe its plan: {written}"
);
} else {
assert_eq!(code, 4, "a platform without Apple's setup says so");
}
}
/// Locks what `status` and `events` say after the daemon replaced one that lost the MIDI
/// service, that `dismiss-warning` clears it for everyone, and that none of it appears otherwise.
///
/// A USB MIDI device dropping off its hub can crash `MIDIServer`. The daemon replaces itself and
/// recovers in seconds, but a program that plays cues stays attached to the dead service and misses
/// the next cue; before this, the only trace was an event nobody read. `status` now warns above its table, scripts read the time from
/// `midi_server_replaced_at`, and the event has a kind of its own. The time is when the loss was
/// found, handed over by the process that found it, not when the replacement started; and
/// dismissing the warning, from the window or here, clears it in the daemon, so a warning
/// dismissed in one place no longer shows in another. 1_790_521_263 seconds after the epoch is
/// 2026-09-27 15:01:03 UTC.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_daemon_that_replaced_one_after_the_midi_service_died_warns_until_dismissed() {
let lost_at = jiff::Timestamp::from_second(1_790_521_263).unwrap();
for (name, replaced) in [("replaced", true), ("started plainly", false)] {
let (daemon, socket) = serving().await;
if replaced {
daemon
.note_replaced_after_midi_server_lost(Some(lost_at))
.await;
}
let (json, table, events, dismissed, dismissed_again, after) =
tokio::task::spawn_blocking(move || {
(
cli(&socket, &["status", "--json"]),
cli(&socket, &["status"]),
cli(&socket, &["events", "--json"]),
cli(&socket, &["dismiss-warning", "--json"]),
cli(&socket, &["dismiss-warning", "--json"]),
cli(&socket, &["status", "--json"]),
)
})
.await
.unwrap();
assert_eq!(json.0, 0, "{name}: status must succeed: {json:?}");
let parse = |out: &str| -> serde_json::Value { serde_json::from_str(out).expect("json") };
let status = parse(&json.1);
let events = parse(&events.1);
let recorded = events
.as_array()
.expect("a list of events")
.iter()
.any(|event| event["kind"] == "midi_server_replaced");
assert_eq!(
status["midi_server_replaced_at"].as_str(),
replaced.then_some("2026-09-27T15:01:03Z"),
"{name}: status --json must carry when the MIDI service was found gone, only after it \
was: {status}"
);
assert_eq!(
table.1.starts_with("warning: the MIDI service stopped at "),
replaced,
"{name}: status must warn above its table only after the MIDI service was lost: {}",
table.1
);
assert_eq!(
recorded, replaced,
"{name}: the history must hold a midi_server_replaced event only after the MIDI \
service was lost: {events}"
);
assert_eq!(
(
parse(&dismissed.1)["result"]["dismissed"].as_bool(),
parse(&dismissed_again.1)["result"]["dismissed"].as_bool(),
),
(Some(replaced), Some(false)),
"{name}: dismissing must clear a warning there is, once, and say when there is none"
);
assert!(
parse(&after.1)["midi_server_replaced_at"].is_null(),
"{name}: a dismissed warning must be gone from status: {}",
after.1
);
}
}