aboutsummaryrefslogtreecommitdiff
path: root/src/api/schema/tests.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 02:43:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 03:47:07 +0300
commitfbd20ad6e488fb3b9efc1612fdffee6e672976c1 (patch)
treed21daf93241786f8aa6fde23b41cdb9e15182b79 /src/api/schema/tests.rs
parentd35c642ef36ee5ac2afe7c1b18ad5a717bc807d8 (diff)
feat: add plugin v1 system
Add manifest-driven plugin actions, event hooks, pane entrypoints, link handlers, and host APIs. Support plugin link, GitHub install, build commands, installed plugin registry, pane movement, runtime context, and plugin docs.
Diffstat (limited to 'src/api/schema/tests.rs')
-rw-r--r--src/api/schema/tests.rs975
1 files changed, 975 insertions, 0 deletions
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
new file mode 100644
index 00000000..c1713972
--- /dev/null
+++ b/src/api/schema/tests.rs
@@ -0,0 +1,975 @@
+use std::collections::HashMap;
+
+use super::*;
+
+#[test]
+fn request_round_trips_for_pane_read() {
+ let request = Request {
+ id: "req_1".into(),
+ method: Method::PaneRead(PaneReadParams {
+ pane_id: "p_1".into(),
+ source: ReadSource::Recent,
+ lines: Some(80),
+ format: ReadFormat::Text,
+ strip_ansi: true,
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_report_agent() {
+ let request = Request {
+ id: "req_hook".into(),
+ method: Method::PaneReportAgent(PaneReportAgentParams {
+ pane_id: "1-1".into(),
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ state: PaneAgentState::Working,
+ message: Some("thinking".into()),
+ custom_status: Some("indexing".into()),
+ seq: Some(42),
+ agent_session_id: Some("pi-session".into()),
+ agent_session_path: Some("/tmp/pi-session.jsonl".into()),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_report_agent_session() {
+ let request = Request {
+ id: "req_session".into(),
+ method: Method::PaneReportAgentSession(PaneReportAgentSessionParams {
+ pane_id: "1-1".into(),
+ source: "herdr:claude".into(),
+ agent: "claude".into(),
+ seq: Some(42),
+ agent_session_id: Some("claude-session".into()),
+ agent_session_path: None,
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_report_metadata() {
+ let request = Request {
+ id: "req_metadata".into(),
+ method: Method::PaneReportMetadata(PaneReportMetadataParams {
+ pane_id: "1-1".into(),
+ source: "user:claude-title".into(),
+ agent: Some("claude".into()),
+ applies_to_source: Some("herdr:claude".into()),
+ title: Some("Refactor auth".into()),
+ display_agent: Some("Claude auth".into()),
+ custom_status: Some("refactor auth".into()),
+ state_labels: HashMap::from([("working".into(), "deep in the mines".into())]),
+ clear_title: false,
+ clear_display_agent: false,
+ clear_custom_status: false,
+ clear_state_labels: false,
+ seq: Some(42),
+ ttl_ms: Some(3_600_000),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_clear_agent_authority() {
+ let request = Request {
+ id: "req_clear".into(),
+ method: Method::PaneClearAgentAuthority(PaneClearAgentAuthorityParams {
+ pane_id: "1-1".into(),
+ source: Some("herdr:pi".into()),
+ seq: Some(42),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_pane_release_agent() {
+ let request = Request {
+ id: "req_release".into(),
+ method: Method::PaneReleaseAgent(PaneReleaseAgentParams {
+ pane_id: "1-1".into(),
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ seq: Some(42),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_uses_dot_method_names() {
+ let request = Request {
+ id: "req_1".into(),
+ method: Method::WorkspaceCreate(WorkspaceCreateParams {
+ cwd: Some("/tmp".into()),
+ focus: true,
+ label: Some("api".into()),
+ env: Default::default(),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "workspace.create");
+}
+
+#[test]
+fn request_round_trips_for_server_stop() {
+ let request = Request {
+ id: "req_stop".into(),
+ method: Method::ServerStop(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.stop");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_server_reload_config() {
+ let request = Request {
+ id: "req_reload".into(),
+ method: Method::ServerReloadConfig(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.reload_config");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_server_reload_agent_manifests() {
+ let request = Request {
+ id: "req_reload_agent_manifests".into(),
+ method: Method::ServerReloadAgentManifests(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.reload_agent_manifests");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_server_agent_manifests() {
+ let request = Request {
+ id: "req_agent_manifests".into(),
+ method: Method::ServerAgentManifests(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.agent_manifests");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn request_round_trips_for_agent_explain() {
+ let request = Request {
+ id: "req_agent_explain".into(),
+ method: Method::AgentExplain(AgentTarget {
+ target: "agent-1".into(),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "agent.explain");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn notification_show_request_parses() {
+ let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed","body":"api workspace","position":"top-left","sound":"request"}}"#;
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::NotificationShow(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(params.title, "build failed");
+ assert_eq!(params.body.as_deref(), Some("api workspace"));
+ assert_eq!(
+ params.position,
+ Some(crate::config::ToastHerdrPosition::TopLeft)
+ );
+ assert_eq!(params.sound, NotificationShowSound::Request);
+}
+
+#[test]
+fn notification_show_sound_defaults_to_none() {
+ let json = r#"{"id":"req_1","method":"notification.show","params":{"title":"build failed"}}"#;
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::NotificationShow(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+
+ assert_eq!(params.sound, NotificationShowSound::None);
+}
+
+#[test]
+fn client_window_title_requests_round_trip() {
+ let set = Request {
+ id: "req_title_set".into(),
+ method: Method::ClientWindowTitleSet(ClientWindowTitleSetParams {
+ title: "herdr api".into(),
+ }),
+ };
+ let json = serde_json::to_value(&set).unwrap();
+ assert_eq!(json["method"], "client.window_title.set");
+ assert_eq!(json["params"]["title"], "herdr api");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, set);
+
+ let clear = Request {
+ id: "req_title_clear".into(),
+ method: Method::ClientWindowTitleClear(EmptyParams::default()),
+ };
+ let json = serde_json::to_value(&clear).unwrap();
+ assert_eq!(json["method"], "client.window_title.clear");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, clear);
+}
+
+#[test]
+fn unknown_method_is_rejected() {
+ let json = r#"{"id":"req_1","method":"nope","params":{}}"#;
+ let err = serde_json::from_str::<Request>(json)
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("unknown variant"));
+}
+
+#[test]
+fn missing_required_params_are_rejected() {
+ let json = r#"{"id":"req_1","method":"pane.send_text","params":{"pane_id":"p_1"}}"#;
+ let err = serde_json::from_str::<Request>(json)
+ .unwrap_err()
+ .to_string();
+ assert!(err.contains("text"));
+}
+
+#[test]
+fn pane_send_input_defaults_to_empty_text_and_keys() {
+ let json = r#"
+ {
+ "id": "req_1",
+ "method": "pane.send_input",
+ "params": {
+ "pane_id": "p_1"
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::PaneSendInput(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(params.pane_id, "p_1");
+ assert!(params.text.is_empty());
+ assert!(params.keys.is_empty());
+}
+
+#[test]
+fn pane_wait_for_output_defaults_strip_ansi_to_true() {
+ let json = r#"
+ {
+ "id": "req_1",
+ "method": "pane.wait_for_output",
+ "params": {
+ "pane_id": "p_1",
+ "source": "recent",
+ "match": { "type": "substring", "value": "ready" }
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::PaneWaitForOutput(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert!(params.strip_ansi);
+}
+
+#[test]
+fn pane_read_defaults_to_text_format() {
+ let json = r#"
+ {
+ "id": "req_1",
+ "method": "pane.read",
+ "params": {
+ "pane_id": "p_1",
+ "source": "visible"
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::PaneRead(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(params.format, ReadFormat::Text);
+}
+
+#[test]
+fn pane_current_request_round_trips() {
+ let request = Request {
+ id: "req_current".into(),
+ method: Method::PaneCurrent(PaneCurrentParams {
+ caller_pane_id: Some("w1-1".into()),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "pane.current");
+ assert_eq!(json["params"]["caller_pane_id"], "w1-1");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn pane_process_info_request_round_trips() {
+ let request = Request {
+ id: "req_process_info".into(),
+ method: Method::PaneProcessInfo(PaneProcessInfoParams {
+ pane_id: Some("w1-1".into()),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "pane.process_info");
+ assert_eq!(json["params"]["pane_id"], "w1-1");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}
+
+#[test]
+fn event_envelope_round_trips() {
+ let event = EventEnvelope {
+ event: EventKind::PaneOutputChanged,
+ data: EventData::PaneOutputChanged {
+ pane_id: "p_1".into(),
+ workspace_id: "w_1".into(),
+ revision: 42,
+ },
+ };
+
+ let json = serde_json::to_string(&event).unwrap();
+ let restored: EventEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, event);
+}
+
+#[test]
+fn subscribe_request_parses_parameterized_subscriptions() {
+ let json = r#"
+ {
+ "id": "sub_1",
+ "method": "events.subscribe",
+ "params": {
+ "subscriptions": [
+ {
+ "type": "pane.output_matched",
+ "pane_id": "p_1_1",
+ "source": "recent",
+ "lines": 200,
+ "match": { "type": "substring", "value": "auth: received" }
+ },
+ {
+ "type": "pane.agent_status_changed",
+ "pane_id": "p_1_1",
+ "agent_status": "done"
+ }
+ ]
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::EventsSubscribe(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(params.subscriptions.len(), 2);
+ assert!(matches!(
+ &params.subscriptions[0],
+ Subscription::PaneOutputMatched {
+ pane_id,
+ source: ReadSource::Recent,
+ lines: Some(200),
+ r#match: OutputMatch::Substring { value },
+ strip_ansi: true,
+ } if pane_id == "p_1_1" && value == "auth: received"
+ ));
+ assert!(matches!(
+ &params.subscriptions[1],
+ Subscription::PaneAgentStatusChanged {
+ pane_id,
+ agent_status: Some(AgentStatus::Done),
+ } if pane_id == "p_1_1"
+ ));
+}
+
+#[test]
+fn subscription_event_envelope_round_trips() {
+ let event = SubscriptionEventEnvelope {
+ event: SubscriptionEventKind::PaneOutputMatched,
+ data: SubscriptionEventData::PaneOutputMatched(PaneOutputMatchedEvent {
+ pane_id: "p_1_1".into(),
+ matched_line: "auth: received".into(),
+ read: PaneReadResult {
+ pane_id: "p_1_1".into(),
+ workspace_id: "w_1".into(),
+ tab_id: "t_1_1".into(),
+ source: ReadSource::Recent,
+ format: ReadFormat::Text,
+ text: "auth: received\n".into(),
+ revision: 0,
+ truncated: false,
+ },
+ }),
+ };
+
+ let json = serde_json::to_string(&event).unwrap();
+ assert!(json.contains("\"event\":\"pane.output_matched\""));
+ let restored: SubscriptionEventEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, event);
+}
+
+#[test]
+fn success_response_round_trips() {
+ let response = SuccessResponse {
+ id: "req_1".into(),
+ result: ResponseResult::Pong {
+ version: "0.1.2".into(),
+ protocol: 6,
+ capabilities: Some(ServerCapabilities { live_handoff: true }),
+ },
+ };
+
+ let json = serde_json::to_string(&response).unwrap();
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn worktree_request_and_response_round_trip() {
+ let request = Request {
+ id: "req_worktree".into(),
+ method: Method::WorktreeCreate(WorktreeCreateParams {
+ workspace_id: Some("1".into()),
+ branch: Some("worktree/api".into()),
+ base: Some("HEAD".into()),
+ focus: true,
+ ..WorktreeCreateParams::default()
+ }),
+ };
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+
+ let response = SuccessResponse {
+ id: "req_worktree".into(),
+ result: ResponseResult::WorktreeCreated {
+ workspace: WorkspaceInfo {
+ workspace_id: "w_1".into(),
+ number: 2,
+ label: "herdr".into(),
+ focused: true,
+ pane_count: 1,
+ tab_count: 1,
+ active_tab_id: "w_1:1".into(),
+ agent_status: AgentStatus::Unknown,
+ worktree: Some(WorkspaceWorktreeInfo {
+ repo_key: "/repo/herdr/.git".into(),
+ repo_name: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: "/worktrees/herdr/worktree-api".into(),
+ is_linked_worktree: true,
+ }),
+ },
+ tab: TabInfo {
+ tab_id: "w_1:1".into(),
+ workspace_id: "w_1".into(),
+ number: 1,
+ label: "herdr".into(),
+ focused: true,
+ pane_count: 1,
+ agent_status: AgentStatus::Unknown,
+ },
+ root_pane: PaneInfo {
+ pane_id: "w_1-1".into(),
+ terminal_id: "term_1".into(),
+ workspace_id: "w_1".into(),
+ tab_id: "w_1:1".into(),
+ focused: true,
+ cwd: Some("/worktrees/herdr/worktree-api".into()),
+ foreground_cwd: None,
+ label: None,
+ agent: None,
+ title: None,
+ display_agent: None,
+ agent_status: AgentStatus::Unknown,
+ custom_status: None,
+ state_labels: HashMap::new(),
+ agent_session: None,
+ revision: 0,
+ },
+ worktree: WorktreeInfo {
+ path: "/worktrees/herdr/worktree-api".into(),
+ branch: Some("worktree/api".into()),
+ is_bare: false,
+ is_detached: false,
+ is_prunable: false,
+ is_linked_worktree: true,
+ open_workspace_id: Some("w_1".into()),
+ label: "herdr".into(),
+ },
+ },
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ assert!(json.contains("\"type\":\"worktree_created\""));
+ assert!(json.contains("\"worktree\""));
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn worktree_lifecycle_events_round_trip() {
+ let subscription = Request {
+ id: "sub_worktrees".into(),
+ method: Method::EventsSubscribe(EventsSubscribeParams {
+ subscriptions: vec![
+ Subscription::WorktreeCreated {},
+ Subscription::WorktreeOpened {},
+ Subscription::WorktreeRemoved {},
+ ],
+ }),
+ };
+ let json = serde_json::to_string(&subscription).unwrap();
+ assert!(json.contains("\"type\":\"worktree.created\""));
+ assert!(json.contains("\"type\":\"worktree.opened\""));
+ assert!(json.contains("\"type\":\"worktree.removed\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, subscription);
+
+ let workspace = WorkspaceInfo {
+ workspace_id: "w_2".into(),
+ number: 2,
+ label: "herdr".into(),
+ focused: true,
+ pane_count: 1,
+ tab_count: 1,
+ active_tab_id: "w_2:1".into(),
+ agent_status: AgentStatus::Unknown,
+ worktree: Some(WorkspaceWorktreeInfo {
+ repo_key: "/repo/herdr/.git".into(),
+ repo_name: "herdr".into(),
+ repo_root: "/repo/herdr".into(),
+ checkout_path: "/worktrees/herdr/worktree-api".into(),
+ is_linked_worktree: true,
+ }),
+ };
+ let worktree = WorktreeInfo {
+ path: "/worktrees/herdr/worktree-api".into(),
+ branch: Some("worktree/api".into()),
+ is_bare: false,
+ is_detached: false,
+ is_prunable: false,
+ is_linked_worktree: true,
+ open_workspace_id: Some("w_2".into()),
+ label: "herdr".into(),
+ };
+
+ for event in [
+ EventEnvelope {
+ event: EventKind::WorktreeCreated,
+ data: EventData::WorktreeCreated {
+ workspace: workspace.clone(),
+ worktree: worktree.clone(),
+ },
+ },
+ EventEnvelope {
+ event: EventKind::WorktreeOpened,
+ data: EventData::WorktreeOpened {
+ workspace: workspace.clone(),
+ worktree: worktree.clone(),
+ already_open: false,
+ },
+ },
+ EventEnvelope {
+ event: EventKind::WorktreeRemoved,
+ data: EventData::WorktreeRemoved {
+ workspace_id: "w_2".into(),
+ worktree: WorktreeInfo {
+ open_workspace_id: None,
+ ..worktree.clone()
+ },
+ forced: false,
+ },
+ },
+ EventEnvelope {
+ event: EventKind::WorkspaceClosed,
+ data: EventData::WorkspaceClosed {
+ workspace_id: "w_2".into(),
+ workspace: Some(workspace.clone()),
+ },
+ },
+ ] {
+ let json = serde_json::to_string(&event).unwrap();
+ let restored: EventEnvelope = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, event);
+ }
+}
+
+#[test]
+fn plugin_link_list_unlink_round_trip() {
+ let link = Request {
+ id: "plugin_link".into(),
+ method: Method::PluginLink(PluginLinkParams {
+ path: "/plugins/worktree-bootstrap".into(),
+ enabled: true,
+ source: None,
+ }),
+ };
+ let json = serde_json::to_string(&link).unwrap();
+ assert!(json.contains("\"method\":\"plugin.link\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, link);
+
+ let list = Request {
+ id: "plugin_list".into(),
+ method: Method::PluginList(PluginListParams {
+ plugin_id: Some("example.worktree-bootstrap".into()),
+ }),
+ };
+ let json = serde_json::to_string(&list).unwrap();
+ assert!(json.contains("\"method\":\"plugin.list\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, list);
+
+ let unlink = Request {
+ id: "plugin_unlink".into(),
+ method: Method::PluginUnlink(PluginUnlinkParams {
+ plugin_id: "example.worktree-bootstrap".into(),
+ }),
+ };
+ let json = serde_json::to_string(&unlink).unwrap();
+ assert!(json.contains("\"method\":\"plugin.unlink\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, unlink);
+
+ let plugin = InstalledPluginInfo {
+ plugin_id: "example.worktree-bootstrap".into(),
+ name: "Worktree Bootstrap".into(),
+ version: "0.1.0".into(),
+ description: Some("Prepare new worktrees".into()),
+ manifest_path: "/plugins/worktree-bootstrap/herdr-plugin.toml".into(),
+ plugin_root: "/plugins/worktree-bootstrap".into(),
+ enabled: true,
+ platforms: None,
+ build: vec![PluginManifestBuild {
+ platforms: None,
+ command: vec!["bun".into(), "install".into()],
+ }],
+ actions: vec![PluginManifestAction {
+ id: "bootstrap".into(),
+ title: "Bootstrap worktree".into(),
+ description: None,
+ contexts: vec![PluginActionContext::Workspace],
+ platforms: None,
+ command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()],
+ }],
+ events: vec![PluginManifestEventHook {
+ on: "worktree.created".into(),
+ platforms: None,
+ command: vec!["bun".into(), "run".into(), "bootstrap.ts".into()],
+ }],
+ panes: vec![PluginManifestPane {
+ id: "board".into(),
+ title: "Board".into(),
+ description: None,
+ platforms: None,
+ placement: PluginPanePlacement::Overlay,
+ command: vec!["bun".into(), "run".into(), "board.ts".into()],
+ }],
+ link_handlers: vec![PluginManifestLinkHandler {
+ id: "github-pr".into(),
+ title: "Open GitHub PR".into(),
+ pattern: "^https://github.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(),
+ action: "bootstrap".into(),
+ platforms: None,
+ }],
+ source: Default::default(),
+ warnings: vec![],
+ };
+
+ for response in [
+ SuccessResponse {
+ id: "plugin_link".into(),
+ result: ResponseResult::PluginLinked {
+ plugin: plugin.clone(),
+ },
+ },
+ SuccessResponse {
+ id: "plugin_list".into(),
+ result: ResponseResult::PluginList {
+ plugins: vec![plugin.clone()],
+ },
+ },
+ SuccessResponse {
+ id: "plugin_unlink".into(),
+ result: ResponseResult::PluginUnlinked {
+ plugin_id: plugin.plugin_id.clone(),
+ removed: true,
+ },
+ },
+ ] {
+ let json = serde_json::to_string(&response).unwrap();
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+ }
+}
+
+#[test]
+fn layout_export_apply_round_trip() {
+ let root = LayoutNode::Split {
+ direction: SplitDirection::Right,
+ ratio: 0.6,
+ first: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("editor".into()),
+ cwd: Some("/repo".into()),
+ ..Default::default()
+ },
+ }),
+ second: Box::new(LayoutNode::Pane {
+ pane: LayoutPane {
+ label: Some("tests".into()),
+ command: Some(vec!["sh".into(), "-c".into(), "just test".into()]),
+ env: HashMap::from([("HERDR_ROLE".into(), "tests".into())]),
+ ..Default::default()
+ },
+ }),
+ };
+
+ let export = Request {
+ id: "layout_export".into(),
+ method: Method::LayoutExport(LayoutExportParams {
+ tab_id: Some("w1:1".into()),
+ pane_id: None,
+ }),
+ };
+ let json = serde_json::to_string(&export).unwrap();
+ assert!(json.contains("\"method\":\"layout.export\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, export);
+
+ let apply = Request {
+ id: "layout_apply".into(),
+ method: Method::LayoutApply(LayoutApplyParams {
+ workspace_id: Some("w1".into()),
+ tab_id: None,
+ tab_label: Some("dev".into()),
+ focus: true,
+ root: root.clone(),
+ }),
+ };
+ let json = serde_json::to_string(&apply).unwrap();
+ assert!(json.contains("\"method\":\"layout.apply\""));
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, apply);
+
+ let response = SuccessResponse {
+ id: "layout_export".into(),
+ result: ResponseResult::LayoutExport {
+ layout: LayoutDescription {
+ workspace_id: "w1".into(),
+ tab_id: "w1:1".into(),
+ zoomed: false,
+ focused_pane_id: "w1-1".into(),
+ root,
+ },
+ },
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn create_response_round_trips_with_root_pane() {
+ let response = SuccessResponse {
+ id: "req_2".into(),
+ result: ResponseResult::TabCreated {
+ tab: TabInfo {
+ tab_id: "w_1:2".into(),
+ workspace_id: "w_1".into(),
+ number: 2,
+ label: "review".into(),
+ focused: false,
+ pane_count: 1,
+ agent_status: AgentStatus::Unknown,
+ },
+ root_pane: PaneInfo {
+ pane_id: "w_1-3".into(),
+ terminal_id: "term_example".into(),
+ workspace_id: "w_1".into(),
+ tab_id: "w_1:2".into(),
+ focused: false,
+ cwd: Some("/tmp/review".into()),
+ foreground_cwd: None,
+ label: None,
+ agent: None,
+ title: None,
+ display_agent: None,
+ agent_status: AgentStatus::Unknown,
+ custom_status: None,
+ state_labels: HashMap::new(),
+ agent_session: None,
+ revision: 0,
+ },
+ },
+ };
+
+ let json = serde_json::to_string(&response).unwrap();
+ assert!(json.contains("\"type\":\"tab_created\""));
+ assert!(json.contains("\"root_pane\""));
+ let restored: SuccessResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn error_response_round_trips() {
+ let response = ErrorResponse {
+ id: "req_1".into(),
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: "pane p_1 not found".into(),
+ },
+ };
+
+ let json = serde_json::to_string(&response).unwrap();
+ let restored: ErrorResponse = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, response);
+}
+
+#[test]
+fn event_wait_parses_typed_match() {
+ let json = r#"
+ {
+ "id": "req_9",
+ "method": "events.wait",
+ "params": {
+ "match_event": {
+ "event": "pane_agent_status_changed",
+ "pane_id": "p_1",
+ "agent_status": "done"
+ },
+ "timeout_ms": 30000
+ }
+ }
+ "#;
+
+ let request: Request = serde_json::from_str(json).unwrap();
+ let Method::EventsWait(params) = request.method else {
+ panic!("wrong method parsed");
+ };
+ assert_eq!(
+ params.match_event,
+ EventMatch::PaneAgentStatusChanged {
+ pane_id: "p_1".into(),
+ agent_status: AgentStatus::Done,
+ }
+ );
+}
+
+#[test]
+fn plugin_action_list_and_invoke_round_trips() {
+ let list = Request {
+ id: "req_plugin_action_list".into(),
+ method: Method::PluginActionList(PluginActionListParams {
+ plugin_id: Some("example.issue-flow".into()),
+ }),
+ };
+ let json = serde_json::to_value(&list).unwrap();
+ assert_eq!(json["method"], "plugin.action.list");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, list);
+
+ let invoke = Request {
+ id: "req_plugin_action_invoke".into(),
+ method: Method::PluginActionInvoke(PluginActionInvokeParams {
+ plugin_id: Some("example.issue-flow".into()),
+ action_id: "assign-issue".into(),
+ context: None,
+ }),
+ };
+ let json = serde_json::to_value(&invoke).unwrap();
+ assert_eq!(json["method"], "plugin.action.invoke");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, invoke);
+
+ let action_info = PluginActionInfo {
+ plugin_id: "example.issue-flow".into(),
+ action_id: "assign-issue".into(),
+ title: "Assign Issue".into(),
+ description: Some("Open the issue assignment UI".into()),
+ contexts: vec![PluginActionContext::Workspace, PluginActionContext::Pane],
+ command: vec!["assign".into(), "--issue".into()],
+ platforms: Some(vec![PluginPlatform::Linux, PluginPlatform::Macos]),
+ };
+ assert_eq!(
+ action_info.qualified_id(),
+ "example.issue-flow.assign-issue"
+ );
+ let json = serde_json::to_string(&action_info).unwrap();
+ let restored: PluginActionInfo = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, action_info);
+}
+
+#[test]
+fn plugin_pane_open_request_round_trips() {
+ let request = Request {
+ id: "req_plugin_pane".into(),
+ method: Method::PluginPaneOpen(PluginPaneOpenParams {
+ plugin_id: "example.board".into(),
+ entrypoint: "board".into(),
+ placement: Some(PluginPanePlacement::Zoomed),
+ workspace_id: None,
+ target_pane_id: Some("1-1".into()),
+ direction: Some(SplitDirection::Right),
+ cwd: Some("/tmp".into()),
+ focus: true,
+ env: [("HERDR_ROLE".to_string(), "board".to_string())].into(),
+ }),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "plugin.pane.open");
+ assert_eq!(json["params"]["env"]["HERDR_ROLE"], "board");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+}