use super::*;
#[test]
fn command_round_trips() {
let cases = [
Command::Spawn {
command: "echo hi".into(),
cwd: PathBuf::from("/tmp"),
group: None,
},
Command::Spawn {
command: "ls".into(),
cwd: PathBuf::from(OsString::from_vec(b"/tmp/\xff\xfe dir".to_vec())),
group: None,
},
Command::Spawn {
command: "make".into(),
cwd: PathBuf::from("/tmp"),
group: Some("build".into()),
},
Command::Kill { id: 7 },
Command::Remove { id: 3 },
Command::Restart { id: 4 },
Command::Tag { id: 2, on: true },
Command::SetGroup {
id: 2,
group: Some("infra".into()),
},
Command::SetGroup { id: 2, group: None },
Command::SetName {
id: 2,
name: Some("api server".into()),
},
Command::SetName { id: 2, name: None },
Command::Resize {
rows: 30,
cols: 100,
},
Command::Watch {
id: Some(5),
attached: true,
},
Command::Watch {
id: Some(5),
attached: false,
},
Command::Watch {
id: None,
attached: false,
},
Command::Input {
id: 1,
bytes: vec![0, 27, 91, 255],
},
Command::Paste {
id: 6,
bytes: b"line1\nline2\x1b[201~\xff".to_vec(),
},
Command::Mouse {
id: 8,
kind: MouseKind::WheelDown,
col: 79,
row: 23,
},
Command::Mouse {
id: 8,
kind: MouseKind::Press(MouseBtn::Left),
col: 0,
row: 0,
},
Command::Mouse {
id: 8,
kind: MouseKind::Drag(MouseBtn::Middle),
col: 10,
row: 5,
},
Command::Mouse {
id: 8,
kind: MouseKind::Release(MouseBtn::Right),
col: 10,
row: 5,
},
Command::Key {
id: 9,
code: Key::Char('λ'),
mods: Mods::default(),
},
Command::Key {
id: 9,
code: Key::F(7),
mods: Mods {
ctrl: true,
..Mods::default()
},
},
Command::Key {
id: 9,
code: Key::Left,
mods: Mods {
shift: true,
alt: true,
ctrl: true,
},
},
Command::Key {
id: 9,
code: Key::Enter,
mods: Mods::default(),
},
Command::Scrollback {
id: 3,
action: ScrollAction::Up(23),
},
Command::Scrollback {
id: 3,
action: ScrollAction::Down(1),
},
Command::Scrollback {
id: 3,
action: ScrollAction::Top,
},
Command::Scrollback {
id: 3,
action: ScrollAction::Live,
},
Command::SaveSession {
name: "work".into(),
},
Command::LoadSession {
name: "home".into(),
},
Command::LoadRecovery {
stem: "20260714-093015-4242".into(),
},
Command::ListSessions,
Command::Shutdown,
];
for c in cases {
let (k, p) = encode_command(&c);
assert_eq!(decode_command(k, &p).as_ref(), Some(&c), "round-trip {c:?}");
}
}
#[test]
fn hello_ok_round_trips() {
let ack = Event::HelloOk;
let (k, p) = encode_event(&ack);
assert_eq!(k, KIND_CONTROL);
assert_eq!(decode_event(k, &p), Some(ack));
}
#[test]
fn hello_round_trips() {
let ctx = LaunchContext {
env: vec![
("PATH".into(), "/usr/bin:/bin".into()),
(
OsString::from_vec(b"BAD\xff\xfe".to_vec()),
OsString::from_vec(b"v\xff".to_vec()),
),
],
cwd: PathBuf::from(OsString::from_vec(b"/home/x\xff\xfe".to_vec())),
};
let (k, p) = encode_hello(&ctx);
assert_eq!(k, KIND_HELLO);
assert_eq!(decode_hello(k, &p), Some((PROTOCOL_VERSION, ctx)));
let empty = LaunchContext {
env: Vec::new(),
cwd: PathBuf::from("/"),
};
let (k, p) = encode_hello(&empty);
assert_eq!(decode_hello(k, &p), Some((PROTOCOL_VERSION, empty)));
}
#[test]
fn hello_requires_its_own_frame_kind() {
let (_, p) = encode_hello(&LaunchContext {
env: Vec::new(),
cwd: PathBuf::from("/"),
});
assert_eq!(decode_hello(KIND_CONTROL, &p), None);
assert_eq!(decode_command(KIND_CONTROL, &p), None);
}
#[test]
fn hello_with_malformed_env_is_rejected() {
for env in [
r#"[["P@TH","L2Jpbg=="]]"#, r#"[["QUFBQUE","L2Jpbg=="]]"#, r#"[["UEFUSA==","AAAA="]]"#, r#"[[[80],[65]]]"#, r#"["PATH=/bin"]"#, ] {
let json = format!(r#"{{"v":4,"cwd":"Lw==","env":{env}}}"#);
assert_eq!(
decode_hello(KIND_HELLO, json.as_bytes()),
None,
"should reject env {env}"
);
}
}
#[test]
fn hello_with_malformed_cwd_is_rejected() {
let json = r#"{"v":3,"cwd":"/home/user","env":[]}"#;
assert_eq!(decode_hello(KIND_HELLO, json.as_bytes()), None);
}
#[test]
fn out_of_range_numerics_are_rejected() {
for json in [
r#"{"t":"resize","rows":65536,"cols":100}"#,
r#"{"t":"resize","rows":30,"cols":65536}"#,
r#"{"t":"mouse","id":1,"k":"wu","col":65536,"row":0}"#,
r#"{"t":"mouse","id":1,"k":"wu","col":0,"row":65536}"#,
r#"{"t":"sb","id":1,"a":"u","n":65536}"#,
r#"{"t":"sb","id":1,"a":"d","n":-1}"#,
] {
assert_eq!(
decode_command(KIND_CONTROL, json.as_bytes()),
None,
"should reject {json}"
);
}
}
#[test]
fn invalid_base64_is_rejected() {
for json in [
r#"{"t":"input","id":1,"bytes":"!!!"}"#,
r#"{"t":"input","id":1,"bytes":[0,27]}"#, r#"{"t":"paste","id":1,"bytes":"AAAA="}"#, r#"{"t":"spawn","command":"ls","cwd":"/tmp/x"}"#, ] {
assert_eq!(
decode_command(KIND_CONTROL, json.as_bytes()),
None,
"should reject {json}"
);
}
}
fn screen_payload(header: &str) -> Vec<u8> {
let mut p = Vec::with_capacity(4 + header.len());
p.extend_from_slice(&(header.len() as u32).to_be_bytes());
p.extend_from_slice(header.as_bytes());
p
}
fn tv(id: u64) -> TaskView {
TaskView {
id,
command: "x".into(),
cwd: PathBuf::from("/"),
tagged: false,
group: None,
name: None,
lifecycle: Lifecycle::Ok,
parked: false,
preview: Preview::floor(String::new()),
started_ago: Duration::from_millis(0),
quiet_ago: None,
finished_ago: None,
}
}
#[test]
fn mistyped_event_members_are_rejected() {
for header in [
r#"{"id":1,"cursor":[0,0],"hide":false,"mouse":false,"alt":false,"ascr":false,"sb":0,"lines":["ok",5]}"#,
r#"{"id":1,"cursor":[65536,0],"hide":false,"mouse":false,"alt":false,"ascr":false,"sb":0,"lines":[]}"#,
] {
assert_eq!(
decode_event(KIND_SCREEN, &screen_payload(header)),
None,
"should reject header {header}"
);
}
for json in [
r#"{"t":"tasks","tasks":[{"id":"nope"}]}"#,
r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"/x","tagged":true,"life":"ok","preview":"","started_ms":0}]}"#,
r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"life":"ok","preview":"","started_ms":0,"group":5}]}"#,
r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":true,"life":"ok","preview":"","started_ms":0,"name":5}]}"#,
r#"{"t":"tasks","tasks":["flat"]}"#,
r#"{"t":"sessions","names":["ok",5]}"#,
] {
assert_eq!(
decode_event(KIND_CONTROL, json.as_bytes()),
None,
"should reject {json}"
);
}
}
#[test]
fn screen_header_without_alt_scroll_is_rejected() {
let header =
r#"{"id":1,"cursor":[0,0],"hide":false,"mouse":false,"alt":true,"sb":0,"lines":[]}"#;
assert_eq!(decode_event(KIND_SCREEN, &screen_payload(header)), None);
}
#[test]
fn tasks_and_status_round_trip() {
let tasks = Event::Tasks(vec![
TaskView {
id: 1,
command: "vim".into(),
cwd: PathBuf::from("/home/x"),
tagged: true,
group: Some("x".into()),
name: Some("editor".into()),
lifecycle: Lifecycle::Idle,
parked: false,
preview: Preview {
text: "~ line".into(),
source: PreviewSource::Title,
rule: None,
frozen: false,
},
started_ago: Duration::from_millis(4200),
quiet_ago: Some(Duration::from_millis(700)),
finished_ago: None,
},
TaskView {
command: "make".into(),
cwd: PathBuf::from(OsString::from_vec(b"/srv/\xff\xfe".to_vec())),
lifecycle: Lifecycle::Active,
started_ago: Duration::from_millis(10),
..tv(2)
},
]);
let (k, p) = encode_event(&tasks);
assert_eq!(k, KIND_CONTROL);
assert_eq!(decode_event(k, &p), Some(tasks));
let status = Event::Status("saved 'x'".into());
let (k, p) = encode_event(&status);
assert_eq!(decode_event(k, &p), Some(status));
}
#[test]
fn set_group_wire_form() {
let (k, p) = encode_command(&Command::SetGroup {
id: 3,
group: Some("infra".into()),
});
assert_eq!(k, KIND_CONTROL);
assert_eq!(
std::str::from_utf8(&p).unwrap(),
r#"{"t":"group","id":3,"g":"infra"}"#
);
let (_, p) = encode_command(&Command::SetGroup { id: 3, group: None });
assert!(!String::from_utf8(p).unwrap().contains("\"g\""));
assert_eq!(
decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":null}"#),
Some(Command::SetGroup { id: 3, group: None })
);
assert_eq!(
decode_command(KIND_CONTROL, br#"{"t":"group","id":3,"g":5}"#),
None
);
}
#[test]
fn set_name_wire_form() {
let (k, p) = encode_command(&Command::SetName {
id: 3,
name: Some("api".into()),
});
assert_eq!(k, KIND_CONTROL);
assert_eq!(
std::str::from_utf8(&p).unwrap(),
r#"{"t":"name","id":3,"n":"api"}"#
);
let (_, p) = encode_command(&Command::SetName { id: 3, name: None });
assert!(!String::from_utf8(p).unwrap().contains("\"n\""));
assert_eq!(
decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":null}"#),
Some(Command::SetName { id: 3, name: None })
);
assert_eq!(
decode_command(KIND_CONTROL, br#"{"t":"name","id":3,"n":5}"#),
None
);
}
#[test]
fn tasks_frame_group_key_is_optional() {
let ungrouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"parked":false}]}"#;
match decode_event(KIND_CONTROL, ungrouped.as_bytes()) {
Some(Event::Tasks(v)) => assert_eq!(v[0].group, None),
other => panic!("expected tasks event, got {other:?}"),
}
let (_, p) = encode_event(&Event::Tasks(vec![tv(1)]));
assert_eq!(std::str::from_utf8(&p).unwrap(), ungrouped);
let grouped = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"group":"infra","life":"ok","preview":"","started_ms":0}]}"#;
match decode_event(KIND_CONTROL, grouped.as_bytes()) {
Some(Event::Tasks(v)) => assert_eq!(v[0].group.as_deref(), Some("infra")),
other => panic!("expected tasks event, got {other:?}"),
}
}
#[test]
fn tasks_frame_name_key_is_optional() {
let unnamed = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"ok","preview":"","src":"floor","frozen":false,"started_ms":0,"parked":false}]}"#;
match decode_event(KIND_CONTROL, unnamed.as_bytes()) {
Some(Event::Tasks(v)) => assert_eq!(v[0].name, None),
other => panic!("expected tasks event, got {other:?}"),
}
let (_, p) = encode_event(&Event::Tasks(vec![tv(1)]));
assert_eq!(std::str::from_utf8(&p).unwrap(), unnamed);
let named = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"name":"build","life":"ok","preview":"","started_ms":0}]}"#;
match decode_event(KIND_CONTROL, named.as_bytes()) {
Some(Event::Tasks(v)) => assert_eq!(v[0].name.as_deref(), Some("build")),
other => panic!("expected tasks event, got {other:?}"),
}
}
#[test]
fn parked_and_age_fields_round_trip() {
let tasks = Event::Tasks(vec![
TaskView {
command: "top".into(),
lifecycle: Lifecycle::Idle,
parked: true,
started_ago: Duration::from_millis(60_000),
quiet_ago: Some(Duration::from_millis(12_000)),
..tv(1)
},
TaskView {
command: "make".into(),
started_ago: Duration::from_millis(60_000),
finished_ago: Some(Duration::from_millis(3_000)),
..tv(2)
},
]);
let (k, p) = encode_event(&tasks);
let s = std::str::from_utf8(&p).unwrap();
assert_eq!(s.matches("\"quiet_ms\"").count(), 1, "frame was {s}");
assert_eq!(s.matches("\"finished_ms\"").count(), 1, "frame was {s}");
assert_eq!(decode_event(k, &p), Some(tasks));
}
#[test]
fn tasks_frame_without_parked_keys_decodes_with_defaults() {
let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"idle","preview":"","started_ms":0},{"id":2,"command":"y","cwd":"Lw==","tagged":false,"life":"active","preview":"","started_ms":0}]}"#;
match decode_event(KIND_CONTROL, old.as_bytes()) {
Some(Event::Tasks(v)) => {
assert!(v[0].parked, "idle derives parked");
assert!(!v[1].parked, "active derives not-parked");
assert_eq!((v[0].quiet_ago, v[0].finished_ago), (None, None));
assert_eq!((v[1].quiet_ago, v[1].finished_ago), (None, None));
}
other => panic!("expected tasks event, got {other:?}"),
}
}
#[test]
fn preview_source_and_frozen_round_trip() {
let base = TaskView {
lifecycle: Lifecycle::Active,
preview: Preview::floor("p".into()),
quiet_ago: Some(Duration::from_millis(1)),
..tv(1)
};
let tasks = Event::Tasks(vec![
base.clone(),
TaskView {
id: 2,
preview: Preview {
source: PreviewSource::Marker,
..base.preview.clone()
},
..base.clone()
},
TaskView {
id: 3,
preview: Preview {
source: PreviewSource::Title,
frozen: true,
..base.preview.clone()
},
..base.clone()
},
TaskView {
id: 4,
preview: Preview {
source: PreviewSource::Anchor,
..base.preview.clone()
},
..base.clone()
},
]);
let (k, p) = encode_event(&tasks);
assert_eq!(decode_event(k, &p), Some(tasks));
let ruled = Event::Tasks(vec![TaskView {
preview: Preview {
source: PreviewSource::Anchor,
rule: Some("claude-status"),
..base.preview.clone()
},
..base.clone()
}]);
let (k, p) = encode_event(&ruled);
assert!(
!String::from_utf8(p.clone())
.unwrap()
.contains("claude-status")
);
match decode_event(k, &p) {
Some(Event::Tasks(v)) => {
assert_eq!(v[0].preview.source, PreviewSource::Anchor);
assert_eq!(v[0].preview.rule, None, "rule must stay daemon-side");
}
other => panic!("expected tasks event, got {other:?}"),
}
}
#[test]
fn tasks_frame_without_preview_keys_decodes_with_floor_defaults() {
let old = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","started_ms":0,"parked":false}]}"#;
match decode_event(KIND_CONTROL, old.as_bytes()) {
Some(Event::Tasks(v)) => {
assert_eq!(v[0].preview.source, PreviewSource::Floor);
assert!(!v[0].preview.frozen);
assert_eq!(v[0].preview.rule, None);
}
other => panic!("expected tasks event, got {other:?}"),
}
let bad = r#"{"t":"tasks","tasks":[{"id":1,"command":"x","cwd":"Lw==","tagged":false,"life":"active","preview":"p","src":"vibes","frozen":false,"started_ms":0,"parked":false}]}"#;
assert_eq!(decode_event(KIND_CONTROL, bad.as_bytes()), None);
}
#[test]
fn sessions_event_round_trips() {
for names in [
vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
Vec::new(),
vec!["my session".to_string(), "café ☕".to_string()],
] {
let ev = Event::Sessions {
names,
recovery: Vec::new(),
};
let (k, p) = encode_event(&ev);
assert_eq!(k, KIND_CONTROL);
assert_eq!(decode_event(k, &p).as_ref(), Some(&ev), "round-trip {ev:?}");
}
}
#[test]
fn sessions_recovery_entries_round_trip_and_pin_the_wire_shape() {
let ev = Event::Sessions {
names: vec!["work".to_string()],
recovery: vec![
RecoveryEntry {
stem: "20260715-070000-22".into(),
label: "autosaved 2026-07-15 07:00".into(),
tasks: 3,
age_secs: 42,
},
RecoveryEntry {
stem: "20260714-093015-11".into(),
label: "autosaved 2026-07-14 09:30".into(),
tasks: 1,
age_secs: 90_000,
},
],
};
let (k, p) = encode_event(&ev);
assert_eq!(k, KIND_CONTROL);
assert_eq!(
std::str::from_utf8(&p).unwrap(),
r#"{"t":"sessions","names":["work"],"recovery":[{"stem":"20260715-070000-22","label":"autosaved 2026-07-15 07:00","tasks":3,"age":42},{"stem":"20260714-093015-11","label":"autosaved 2026-07-14 09:30","tasks":1,"age":90000}]}"#
);
assert_eq!(decode_event(k, &p), Some(ev));
}
#[test]
fn sessions_frame_without_recovery_key_decodes_empty() {
for json in [
r#"{"t":"sessions","names":["a"]}"#,
r#"{"t":"sessions","names":["a"],"recovery":null}"#,
r#"{"t":"sessions","names":["a"],"recovery":"junk"}"#,
] {
assert_eq!(
decode_event(KIND_CONTROL, json.as_bytes()),
Some(Event::Sessions {
names: vec!["a".to_string()],
recovery: Vec::new(),
}),
"should tolerate {json}"
);
}
}
#[test]
fn malformed_recovery_members_drop_without_rejecting_the_event() {
let json = r#"{"t":"sessions","names":[],"recovery":[
{"stem":5,"label":"x","tasks":1,"age":0},
{"label":"x","tasks":1,"age":0},
{"stem":"s1","tasks":1,"age":0},
{"stem":"s2","label":7,"tasks":1,"age":0},
{"stem":"s3","label":"x","age":0},
{"stem":"s4","label":"x","tasks":4294967296,"age":0},
{"stem":"s5","label":"x","tasks":-1,"age":0},
{"stem":"s6","label":"x","tasks":1,"age":-3},
{"stem":"s7","label":"x","tasks":1},
"flat",
{"stem":"good","label":"autosaved","tasks":2,"age":7}
]}"#;
assert_eq!(
decode_event(KIND_CONTROL, json.as_bytes()),
Some(Event::Sessions {
names: Vec::new(),
recovery: vec![RecoveryEntry {
stem: "good".into(),
label: "autosaved".into(),
tasks: 2,
age_secs: 7,
}],
})
);
}
#[test]
fn watch_wire_form_requires_the_attached_flag() {
let (k, p) = encode_command(&Command::Watch {
id: Some(5),
attached: true,
});
assert_eq!(k, KIND_CONTROL);
assert_eq!(
std::str::from_utf8(&p).unwrap(),
r#"{"t":"watch","id":5,"attached":true}"#
);
let (_, p) = encode_command(&Command::Watch {
id: None,
attached: false,
});
assert_eq!(
std::str::from_utf8(&p).unwrap(),
r#"{"t":"watch","id":null,"attached":false}"#
);
for json in [
r#"{"t":"watch","id":5}"#, r#"{"t":"watch","id":null}"#, r#"{"t":"watch","id":5,"attached":null}"#, r#"{"t":"watch","id":5,"attached":1}"#, ] {
assert_eq!(
decode_command(KIND_CONTROL, json.as_bytes()),
None,
"should reject {json}"
);
}
}
#[test]
fn load_recovery_wire_form() {
let (k, p) = encode_command(&Command::LoadRecovery {
stem: "20260714-093015-4242".into(),
});
assert_eq!(k, KIND_CONTROL);
assert_eq!(
std::str::from_utf8(&p).unwrap(),
r#"{"t":"recover","stem":"20260714-093015-4242"}"#
);
for json in [
r#"{"t":"recover"}"#,
r#"{"t":"recover","stem":5}"#,
r#"{"t":"recover","stem":null}"#,
] {
assert_eq!(
decode_command(KIND_CONTROL, json.as_bytes()),
None,
"should reject {json}"
);
}
}
#[test]
fn screen_round_trips_raw_bytes() {
let screen = Event::Screen(ScreenView {
id: 9,
lines: vec!["row0".into(), "row1".into()],
formatted: vec![0x1b, b'[', b'm', 0, 255, b'x'],
cursor: (3, 12),
hide_cursor: false,
wants_mouse: true,
alt_screen: false,
alt_scroll: true,
scrollback: 42,
});
let (k, p) = encode_event(&screen);
assert_eq!(k, KIND_SCREEN);
assert_eq!(decode_event(k, &p), Some(screen));
}
#[test]
fn clipboard_copy_round_trips() {
for ev in [
Event::ClipboardCopy {
id: 1,
kind: ClipboardKind::Clipboard,
text: "hello".into(),
},
Event::ClipboardCopy {
id: 1 << 40,
kind: ClipboardKind::Selection,
text: "sélection λ 🦀".into(),
},
Event::ClipboardCopy {
id: 2,
kind: ClipboardKind::Primary,
text: "primary".into(),
},
Event::ClipboardCopy {
id: 7,
kind: ClipboardKind::Clipboard,
text: "line1\nline2\tcol\u{0}\u{1b}[201~end".into(),
},
] {
let (k, p) = encode_event(&ev);
assert_eq!(k, KIND_CONTROL);
assert_eq!(decode_event(k, &p), Some(ev));
}
}
#[test]
fn clipboard_copy_wire_form() {
for (kind, wire) in [
(
ClipboardKind::Clipboard,
r#"{"t":"clip","id":5,"k":"c","text":"aGk="}"#,
),
(
ClipboardKind::Primary,
r#"{"t":"clip","id":5,"k":"p","text":"aGk="}"#,
),
(
ClipboardKind::Selection,
r#"{"t":"clip","id":5,"k":"s","text":"aGk="}"#,
),
] {
let (k, p) = encode_event(&Event::ClipboardCopy {
id: 5,
kind,
text: "hi".into(),
});
assert_eq!(k, KIND_CONTROL);
assert_eq!(std::str::from_utf8(&p).unwrap(), wire);
}
}
#[test]
fn malformed_clipboard_copy_is_rejected() {
for json in [
r#"{"t":"clip","id":1,"text":"aGk="}"#, r#"{"t":"clip","id":1,"k":"c"}"#, r#"{"t":"clip","k":"c","text":"aGk="}"#, r#"{"t":"clip","id":"1","k":"c","text":"aGk="}"#, r#"{"t":"clip","id":1,"k":"x","text":"aGk="}"#, r#"{"t":"clip","id":1,"k":"c","text":"!!!"}"#, r#"{"t":"clip","id":1,"k":"c","text":"/w=="}"#, r#"{"t":"clip","id":1,"k":"c","text":["aGk="]}"#, ] {
assert_eq!(
decode_event(KIND_CONTROL, json.as_bytes()),
None,
"should reject {json}"
);
}
}