Adds `SessionView { Terminal, Structured }` to maestro-protocol, a
defaulted `view` field on `Session` and `CreateSessionRequest`, and
sidecar round-trip tests for both a legacy payload with no `view` key
and a `Structured` session. Nothing reads the field yet; every other
struct literal across the workspace picks up `Default::default()`
(Terminal) to keep compiling.
Gitea #133.
205 lines
6.6 KiB
Rust
205 lines
6.6 KiB
Rust
//! Renaming a live session (#125): `SessionManager::rename` semantics —
|
|
//! what lands in `metadata.name`, what reaches the sidecar, and what goes out
|
|
//! on the event stream.
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use common::TmuxFixture;
|
|
use maestro_daemon::sidecar::SidecarStore;
|
|
use maestro_daemon::worktree::WorktreeManager;
|
|
use maestro_daemon::SessionManager;
|
|
use maestro_protocol::{
|
|
display, CatalogEvent, CreateSessionRequest, NameSource, MAX_SESSION_NAME_LEN,
|
|
};
|
|
use tempfile::TempDir;
|
|
use uuid::Uuid;
|
|
|
|
struct Fixture {
|
|
manager: SessionManager,
|
|
sidecar: SidecarStore,
|
|
_tmux: TmuxFixture,
|
|
_state_dir: TempDir,
|
|
}
|
|
|
|
fn fixture() -> Fixture {
|
|
let tmux = TmuxFixture::new();
|
|
let state_dir = TempDir::new().unwrap();
|
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
|
let manager = SessionManager::new(
|
|
Arc::new(tmux.tmux.clone()),
|
|
sidecar.clone(),
|
|
WorktreeManager::new(None),
|
|
state_dir.path().join("fifos"),
|
|
);
|
|
Fixture {
|
|
manager,
|
|
sidecar,
|
|
_tmux: tmux,
|
|
_state_dir: state_dir,
|
|
}
|
|
}
|
|
|
|
async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|
let metadata = match name {
|
|
Some(n) => std::collections::HashMap::from([(
|
|
"name".to_string(),
|
|
serde_json::Value::String(n.to_string()),
|
|
)]),
|
|
None => Default::default(),
|
|
};
|
|
manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: "/tmp".into(),
|
|
command: "sleep 60".into(),
|
|
env: Default::default(),
|
|
metadata,
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.id
|
|
}
|
|
|
|
fn name_of(session: &maestro_protocol::Session) -> Option<&str> {
|
|
session.metadata.get("name").and_then(|v| v.as_str())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_on_unknown_session_is_not_found() {
|
|
let fx = fixture();
|
|
let err = fx
|
|
.manager
|
|
.rename(Uuid::new_v4(), "whatever")
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_sets_the_name_in_memory_and_on_disk() {
|
|
let fx = fixture();
|
|
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
|
|
|
|
let renamed = fx.manager.rename(id, " crimson-otter ").await.unwrap();
|
|
assert_eq!(name_of(&renamed), Some("crimson-otter"));
|
|
|
|
let snapshot = fx.manager.snapshot().await;
|
|
let live = snapshot.iter().find(|s| s.id == id).unwrap();
|
|
assert_eq!(name_of(live), Some("crimson-otter"));
|
|
|
|
// A rename a daemon restart would undo is worse than one that failed.
|
|
let persisted = fx.sidecar.read(id).unwrap().unwrap();
|
|
assert_eq!(name_of(&persisted), Some("crimson-otter"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_blank_rename_clears_the_name() {
|
|
let fx = fixture();
|
|
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
|
|
|
|
let renamed = fx.manager.rename(id, " ").await.unwrap();
|
|
assert_eq!(name_of(&renamed), None);
|
|
// Line 1 falls back to the command, per docs/SIDEBAR.md.
|
|
assert_eq!(renamed.command, "sleep 60");
|
|
assert_eq!(name_of(&fx.sidecar.read(id).unwrap().unwrap()), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_caps_a_long_name() {
|
|
let fx = fixture();
|
|
let id = spawn_named(&fx.manager, None).await;
|
|
let long = "x".repeat(MAX_SESSION_NAME_LEN + 20);
|
|
let renamed = fx.manager.rename(id, &long).await.unwrap();
|
|
assert_eq!(
|
|
name_of(&renamed).unwrap().chars().count(),
|
|
MAX_SESSION_NAME_LEN
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rename_broadcasts_a_session_update() {
|
|
let fx = fixture();
|
|
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
|
|
let mut events = fx.manager.events();
|
|
|
|
fx.manager.rename(id, "crimson-otter").await.unwrap();
|
|
|
|
let event = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
|
|
.await
|
|
.expect("an update within the timeout")
|
|
.expect("the event channel stays open");
|
|
match event {
|
|
CatalogEvent::SessionUpdated { session } => {
|
|
assert_eq!(session.id, id);
|
|
assert_eq!(name_of(&session), Some("crimson-otter"));
|
|
}
|
|
other => panic!("expected SessionUpdated, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// #131: a spawn that carried no name gets one of the daemon's own handles,
|
|
/// marked provisional so a first prompt may replace it.
|
|
#[tokio::test]
|
|
async fn a_nameless_spawn_is_given_a_provisional_handle() {
|
|
let fx = fixture();
|
|
let id = spawn_named(&fx.manager, None).await;
|
|
|
|
let snapshot = fx.manager.snapshot().await;
|
|
let live = snapshot.iter().find(|s| s.id == id).unwrap();
|
|
assert!(name_of(live).is_some(), "the daemon names it");
|
|
assert_eq!(display::name_source(live), NameSource::Generated);
|
|
assert!(display::is_provisional_name(live));
|
|
|
|
// It survives a restart the same way the rest of the metadata does.
|
|
let persisted = fx.sidecar.read(id).unwrap().unwrap();
|
|
assert_eq!(name_of(&persisted), name_of(live));
|
|
assert_eq!(display::name_source(&persisted), NameSource::Generated);
|
|
}
|
|
|
|
/// A name the spawn request carried is a person's choice: settled, never
|
|
/// dimmed, never autonamed over (#131).
|
|
#[tokio::test]
|
|
async fn a_requested_name_is_a_user_name() {
|
|
let fx = fixture();
|
|
let id = spawn_named(&fx.manager, Some(" amber-falcon ")).await;
|
|
|
|
let snapshot = fx.manager.snapshot().await;
|
|
let live = snapshot.iter().find(|s| s.id == id).unwrap();
|
|
assert_eq!(name_of(live), Some("amber-falcon"));
|
|
assert_eq!(display::name_source(live), NameSource::User);
|
|
assert!(!display::is_provisional_name(live));
|
|
}
|
|
|
|
/// Renaming a provisional name pins it (#131).
|
|
#[tokio::test]
|
|
async fn a_rename_settles_a_generated_name() {
|
|
let fx = fixture();
|
|
let id = spawn_named(&fx.manager, None).await;
|
|
|
|
let renamed = fx.manager.rename(id, "crimson-otter").await.unwrap();
|
|
assert_eq!(display::name_source(&renamed), NameSource::User);
|
|
assert!(!display::is_provisional_name(&renamed));
|
|
assert_eq!(
|
|
display::name_source(&fx.sidecar.read(id).unwrap().unwrap()),
|
|
NameSource::User
|
|
);
|
|
}
|
|
|
|
/// Clearing the name drops the source with it, so the card falling back to
|
|
/// its command carries no stale provisional marker (#131).
|
|
#[tokio::test]
|
|
async fn clearing_the_name_drops_its_source() {
|
|
let fx = fixture();
|
|
let id = spawn_named(&fx.manager, None).await;
|
|
|
|
let cleared = fx.manager.rename(id, " ").await.unwrap();
|
|
assert_eq!(name_of(&cleared), None);
|
|
assert!(!cleared
|
|
.metadata
|
|
.contains_key(maestro_protocol::NAME_SOURCE_KEY));
|
|
assert!(!display::is_provisional_name(&cleared));
|
|
}
|