mod common;
use common::init_repo;
use gwm::daemon::{
error as rpc_error, handle_line, parse_list_result, parse_worktrees_changed, success, worktrees_changed_notification,
worktrees_differ, INTERNAL_ERROR, INVALID_PARAMS, LIST_REQUEST, METHOD_NOT_FOUND, PARSE_ERROR, SUBSCRIBE_REQUEST,
};
use gwm::json_api::{JsonStatus, JsonWorktree};
use serde_json::Value;
fn sample(name: &str) -> JsonWorktree {
JsonWorktree {
name: name.into(),
id: name.into(),
path: format!("/wt/{name}"),
branch: Some(format!("feat/{name}")),
head: Some("abc1234".into()),
is_main: false,
is_locked: false,
is_prunable: false,
status: JsonStatus {
is_dirty: false,
has_upstream: true,
ahead: 0,
behind: 0,
unknown: false,
},
age_seconds: Some(10),
issue: None,
pr: None,
agents: None,
}
}
fn call(workdir: &std::path::Path, line: &str) -> Value {
let resp = handle_line(workdir, line).expect("a request with an id must get a response");
serde_json::from_str(&resp).expect("response must be valid JSON")
}
#[test]
fn list_method_returns_worktree_array_and_echoes_id() {
let (dir, _repo) = init_repo();
let v = call(dir.path(), r#"{"jsonrpc":"2.0","method":"list","id":7}"#);
assert_eq!(v["jsonrpc"], serde_json::json!("2.0"));
assert_eq!(v["id"], serde_json::json!(7), "response must echo the request id");
let arr = v["result"].as_array().expect("result must be an array");
assert_eq!(arr.len(), 1, "fresh repo has exactly the main worktree");
assert_eq!(arr[0]["is_main"], serde_json::json!(true));
assert!(v.get("error").is_none(), "success must not carry an error");
}
#[test]
fn doctor_method_returns_report_with_severity_and_exit_code() {
let (dir, _repo) = init_repo();
let v = call(dir.path(), r#"{"method":"doctor","id":"d1"}"#);
assert_eq!(v["id"], serde_json::json!("d1"), "string ids round-trip");
let result = &v["result"];
assert!(result["checks"].is_array());
let sev = result["severity"].as_str().unwrap();
assert!(matches!(sev, "ok" | "warning" | "failed"));
assert!(result["exit_code"].is_i64());
}
#[test]
fn path_method_missing_pattern_is_invalid_params() {
let (dir, _repo) = init_repo();
let v = call(dir.path(), r#"{"method":"path","id":1}"#);
assert_eq!(v["error"]["code"], serde_json::json!(INVALID_PARAMS));
assert!(v.get("result").is_none());
}
#[test]
fn path_method_unknown_pattern_is_an_error_not_a_crash() {
let (dir, _repo) = init_repo();
let v = call(
dir.path(),
r#"{"method":"path","params":{"pattern":"does-not-exist"},"id":2}"#,
);
assert!(v.get("error").is_some(), "unknown pattern yields an error envelope");
assert_eq!(v["id"], serde_json::json!(2));
}
#[test]
fn unknown_method_is_method_not_found() {
let (dir, _repo) = init_repo();
let v = call(dir.path(), r#"{"method":"frobnicate","id":3}"#);
assert_eq!(v["error"]["code"], serde_json::json!(METHOD_NOT_FOUND));
assert!(
v["error"]["message"].as_str().unwrap().contains("frobnicate"),
"the message names the offending method"
);
}
#[test]
fn subscribe_over_request_response_is_rejected_with_invalid_params() {
let (dir, _repo) = init_repo();
let v = call(dir.path(), r#"{"method":"subscribe","id":4}"#);
assert_eq!(v["error"]["code"], serde_json::json!(INVALID_PARAMS));
}
#[test]
fn malformed_line_is_parse_error_with_null_id() {
let (dir, _repo) = init_repo();
let v = call(dir.path(), "this is not json {");
assert_eq!(v["error"]["code"], serde_json::json!(PARSE_ERROR));
assert_eq!(v["id"], Value::Null, "a parse error can't know the id");
}
#[test]
fn notification_without_id_gets_no_response() {
let (dir, _repo) = init_repo();
assert!(
handle_line(dir.path(), r#"{"jsonrpc":"2.0","method":"list"}"#).is_none(),
"a notification (no id) must produce no response"
);
}
#[test]
fn explicit_null_id_is_a_request_and_is_answered() {
let (dir, _repo) = init_repo();
let v = call(dir.path(), r#"{"jsonrpc":"2.0","method":"list","id":null}"#);
assert_eq!(v["id"], Value::Null);
assert!(v["result"].is_array());
}
#[test]
fn worktrees_differ_ignores_age_seconds() {
use std::slice::from_ref;
let a = sample("x");
let mut b = a.clone();
b.age_seconds = Some(a.age_seconds.unwrap() + 5);
assert!(
!worktrees_differ(from_ref(&a), from_ref(&b)),
"age-only delta is not a change"
);
}
#[test]
fn worktrees_differ_detects_real_changes() {
use std::slice::from_ref;
let base = sample("x");
assert!(worktrees_differ(from_ref(&base), &[]));
let mut pr = base.clone();
pr.pr = Some(7);
assert!(worktrees_differ(from_ref(&base), from_ref(&pr)));
let mut dirty = base.clone();
dirty.status.is_dirty = true;
assert!(worktrees_differ(from_ref(&base), from_ref(&dirty)));
let renamed = sample("y");
assert!(worktrees_differ(from_ref(&base), from_ref(&renamed)));
assert!(!worktrees_differ(from_ref(&base), from_ref(&base)));
}
#[test]
fn worktrees_differ_notices_agent_changes_including_fresh_activity() {
use gwm::json_api::{JsonAgentSession, JsonWorktreeAgents};
use std::slice::from_ref;
let session = JsonAgentSession {
kind: "claude".into(),
freshness: "active".into(),
last_activity: 100,
id: "s1".into(),
name: None,
};
let base = sample("x");
let mut with = base.clone();
with.agents = Some(JsonWorktreeAgents {
top: session.clone(),
sessions: vec![session],
});
assert!(worktrees_differ(from_ref(&base), from_ref(&with)));
assert!(worktrees_differ(from_ref(&with), from_ref(&base)));
let mut idle = with.clone();
if let Some(a) = idle.agents.as_mut() {
a.sessions[0].freshness = "idle".into();
}
assert!(worktrees_differ(from_ref(&with), from_ref(&idle)));
let mut fresh = with.clone();
if let Some(a) = fresh.agents.as_mut() {
a.top.last_activity += 5;
a.sessions[0].last_activity += 5;
}
assert!(
worktrees_differ(from_ref(&with), from_ref(&fresh)),
"fresh activity is a real change"
);
assert!(!worktrees_differ(from_ref(&with), from_ref(&with)));
}
#[test]
fn subscription_push_skips_phantom_empty_on_transient_error() {
use gwm::daemon::next_subscription_push;
use gwm::error::GwmError;
let snapshot = vec![sample("a"), sample("b")];
assert_eq!(
next_subscription_push(
&Some(snapshot.clone()),
Err(GwmError::Other("transient git lock".into()))
),
None,
"a transient error must not emit a phantom-empty snapshot"
);
assert_eq!(
next_subscription_push(&None, Ok(snapshot.clone())),
Some(snapshot.clone())
);
let grown = vec![sample("a"), sample("b"), sample("c")];
assert_eq!(
next_subscription_push(&Some(snapshot.clone()), Ok(grown.clone())),
Some(grown)
);
assert_eq!(
next_subscription_push(&Some(snapshot.clone()), Ok(vec![])),
Some(vec![])
);
assert_eq!(next_subscription_push(&Some(snapshot.clone()), Ok(snapshot)), None);
}
#[test]
fn parse_list_result_round_trips_a_success_envelope() {
let wts = vec![sample("a"), sample("b")];
let envelope = success(&serde_json::json!(1), serde_json::to_value(&wts).unwrap());
let line = envelope.to_string();
let parsed = parse_list_result(&line).expect("a success envelope must parse");
assert_eq!(parsed, wts, "the decoded list must equal what the server sent");
}
#[test]
fn parse_list_result_surfaces_a_server_error_envelope() {
let envelope = rpc_error(&serde_json::json!(1), INTERNAL_ERROR, "boom");
let err = parse_list_result(&envelope.to_string()).expect_err("an error envelope must not yield an empty list");
assert!(
err.to_string().contains("boom"),
"the server message must surface: {err}"
);
}
#[test]
fn parse_list_result_rejects_a_malformed_line() {
assert!(parse_list_result("not json").is_err());
assert!(parse_list_result(r#"{"jsonrpc":"2.0","id":1}"#).is_err());
}
#[test]
fn parse_worktrees_changed_round_trips_a_notification() {
let wts = vec![sample("x")];
let note = worktrees_changed_notification(&wts);
let line = note.to_string();
let parsed = parse_worktrees_changed(&line).expect("a notification must parse");
assert_eq!(parsed, wts);
}
#[test]
fn parse_worktrees_changed_rejects_a_line_without_params() {
assert!(parse_worktrees_changed("nonsense").is_err());
assert!(parse_worktrees_changed(r#"{"method":"worktrees.changed"}"#).is_err());
}
#[test]
fn client_request_lines_are_valid_rpc_for_their_methods() {
let list: Value = serde_json::from_str(LIST_REQUEST).unwrap();
assert_eq!(list["method"], serde_json::json!("list"));
let sub: Value = serde_json::from_str(SUBSCRIBE_REQUEST).unwrap();
assert_eq!(sub["method"], serde_json::json!("subscribe"));
}
#[test]
fn pipe_user_fragments_stay_distinct_for_punctuation_variants() {
let a = gwm::daemon::pipe_user_fragment("alice.smith");
let b = gwm::daemon::pipe_user_fragment("alice-smith");
assert_ne!(a, b, "punctuation variants must not collide: {a} vs {b}");
}
#[test]
fn pipe_user_fragments_keep_plain_names_readable() {
assert_eq!(gwm::daemon::pipe_user_fragment("kylian"), "kylian");
assert_eq!(gwm::daemon::pipe_user_fragment("User123"), "User123");
}
#[test]
fn pipe_user_fragments_are_valid_pipe_name_material() {
for raw in ["DOMAIN\\alice", "a b", "é", "", "x:y/z"] {
let frag = gwm::daemon::pipe_user_fragment(raw);
assert!(
frag.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
"fragment for {raw:?} must be pipe-safe: {frag:?}"
);
}
}
#[test]
fn pipe_user_fragments_are_injective_on_distinct_inputs() {
let inputs = ["DOMAIN\\alice", "DOMAIN_alice", "domain\\alice", "a.b", "a_b", "a-b"];
let frags: Vec<String> = inputs.iter().map(|i| gwm::daemon::pipe_user_fragment(i)).collect();
for i in 0..frags.len() {
for j in (i + 1)..frags.len() {
assert_ne!(
frags[i], frags[j],
"{} and {} must map to distinct fragments",
inputs[i], inputs[j]
);
}
}
}