aboutsummaryrefslogtreecommitdiff
path: root/src/integration/tests.rs
diff options
context:
space:
mode:
authorEnrico Carlesso <enricocarlesso@gmail.com>2026-07-24 12:05:20 -0700
committerGitHub <noreply@github.com>2026-07-24 22:05:20 +0300
commite9b22084e03880f43d1f1daaf5d9f45c717b77c4 (patch)
tree343c3099fcb0b935f857b7eb00df1e4d9c83d609 /src/integration/tests.rs
parentb56baee9d60a002b5d8c0fe74d5032afad27ef30 (diff)
feat: add grok cli integration for native session restore (#1807)
* feat: add grok cli integration for native session restore tl;dr: adds `herdr integration install grok` (session-identity tier: a SessionStart hook reports the grok session id for native restore; state stays screen-detected via the bundled manifest). Grok merges every ~/.grok/hooks/*.json, so install writes a self-contained hooks/herdr.json registering a SessionStart hook next to hooks/herdr-agent-state.sh, and never touches other hook files. The hook prefers $GROK_SESSION_ID (injected into every grok hook process) and reports through the socket API; herdr:grok is a reserved-native-state source so screen detection stays authoritative. Restore uses `grok --resume <id>`. Additive JSON API change; no protocol bump; the schema artifact is regenerated. refs #1800 * fix: honor GROK_HOME and validate grok hook config in status Review follow-ups: grok_dir now resolves GROK_CONFIG_DIR (herdr test seam) before GROK_HOME (the grok CLI's own config-home override) before ~/.grok, so hook installs land where grok actually looks. Integration status now also validates the herdr-owned hooks/herdr.json next to the hook script — missing, corrupt, or no-longer-referencing configs report the install as outdated instead of masking a hook grok never invokes. refs #1800 * fix: validate the exact grok session hook command in status The config validator matched on a filename substring, so a SessionStart command like `echo herdr-agent-state.sh`, or one invoking the script without the `session` action, still reported the integration as current while grok never delivered a session id. Status now compares against the exact command the installer writes, via a shared builder, and the degradation tests cover both nonfunctional shapes. refs #1800 * fix: require a command-type grok session hook in status validation A config entry carrying the exact generated command under a non-command hook type (e.g. "http") is never executed by grok, so it must not report the integration as current. The validator now also requires type = "command", with a degradation test for the mismatched-type shape. refs #1800 * fix: validate the complete grok hook config refs #1800 --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/integration/tests.rs')
-rw-r--r--src/integration/tests.rs280
1 files changed, 280 insertions, 0 deletions
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index 076a22b6..f695bf52 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -102,6 +102,8 @@ fn clear_integration_path_env() {
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR);
std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR);
+ std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR);
+ std::env::remove_var(GROK_HOME_ENV_VAR);
}
fn kimi_hook_command(hook_path: &Path, action: &str) -> String {
@@ -2777,6 +2779,14 @@ fn bundled_integration_assets_report_session_refs() {
assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent_session"));
assert!(MASTRACODE_HOOK_ASSET.contains("session_start_source"));
assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent"));
+ assert!(GROK_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=grok"));
+ assert!(GROK_HOOK_ASSET.contains("GROK_SESSION_ID"));
+ assert!(GROK_HOOK_ASSET.contains("sessionId"));
+ assert!(GROK_HOOK_ASSET.contains("agent_session_id"));
+ assert!(GROK_HOOK_ASSET.contains("pane.report_agent_session"));
+ assert!(GROK_HOOK_ASSET.contains("herdr:grok"));
+ assert!(!GROK_HOOK_ASSET.contains("\"state\":"));
+ assert!(!GROK_HOOK_ASSET.contains("pane.release_agent"));
}
#[test]
@@ -3331,6 +3341,48 @@ fn install_mastracode_writes_hook_and_updates_hooks_json() {
let _ = fs::remove_dir_all(base);
}
+fn grok_session_command(config: &Value) -> String {
+ config["hooks"]["SessionStart"][0]["hooks"][0]["command"]
+ .as_str()
+ .expect("grok SessionStart command")
+ .to_string()
+}
+
+#[test]
+fn install_grok_writes_hook_and_config() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let grok_dir = base.join(".grok");
+ fs::create_dir_all(&grok_dir).unwrap();
+ std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir);
+
+ let installed = install_grok().unwrap();
+
+ let hooks_dir = grok_dir.join("hooks");
+ assert_eq!(installed.hook_path, hooks_dir.join(GROK_HOOK_INSTALL_NAME));
+ assert_eq!(
+ installed.config_path,
+ hooks_dir.join(GROK_HOOK_CONFIG_INSTALL_NAME)
+ );
+ assert_eq!(
+ fs::read_to_string(&installed.hook_path).unwrap(),
+ GROK_HOOK_ASSET
+ );
+
+ let config: Value =
+ serde_json::from_str(&fs::read_to_string(&installed.config_path).unwrap()).unwrap();
+ assert_eq!(config, grok_hook_config(&installed.hook_path));
+ let session_start = config["hooks"]["SessionStart"].as_array().unwrap();
+ assert_eq!(session_start.len(), 1);
+ let command = grok_session_command(&config);
+ assert!(command.starts_with("sh "));
+ assert!(command.contains("herdr-agent-state.sh"));
+ assert!(command.ends_with(" session"));
+
+ std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
#[test]
fn install_mastracode_removes_v1_lifecycle_hooks() {
let _lock = integration_env_lock();
@@ -3410,6 +3462,26 @@ fn install_mastracode_is_idempotent_for_hook_entries() {
}
#[test]
+fn install_grok_is_idempotent() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let grok_dir = base.join(".grok");
+ fs::create_dir_all(&grok_dir).unwrap();
+ std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir);
+
+ install_grok().unwrap();
+ let first =
+ fs::read_to_string(grok_dir.join("hooks").join(GROK_HOOK_CONFIG_INSTALL_NAME)).unwrap();
+ install_grok().unwrap();
+ let second =
+ fs::read_to_string(grok_dir.join("hooks").join(GROK_HOOK_CONFIG_INSTALL_NAME)).unwrap();
+ assert_eq!(first, second);
+
+ std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
fn uninstall_mastracode_removes_herdr_hooks_and_preserves_others() {
let _lock = integration_env_lock();
let base = unique_base();
@@ -3467,6 +3539,71 @@ fn uninstall_mastracode_removes_herdr_hooks_and_preserves_others() {
}
#[test]
+fn install_grok_errors_when_config_dir_missing() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ // Deliberately do not create the ~/.grok directory ahead of time: the
+ // installer must refuse instead of conjuring a config dir for an agent
+ // that is not installed.
+ let missing = base.join(".grok");
+ std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &missing);
+
+ let err = install_grok().unwrap_err().to_string();
+ assert!(
+ err.contains("grok config directory not found"),
+ "unexpected error: {err}"
+ );
+
+ std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_grok_removes_files() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let grok_dir = base.join(".grok");
+ fs::create_dir_all(&grok_dir).unwrap();
+ std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir);
+
+ install_grok().unwrap();
+ let result = uninstall_grok().unwrap();
+ assert!(result.removed_hook_file);
+ assert!(result.removed_config_file);
+ assert!(!result.hook_path.is_file());
+ assert!(!result.config_path.is_file());
+
+ // Uninstalling again is a no-op.
+ let again = uninstall_grok().unwrap();
+ assert!(!again.removed_hook_file);
+ assert!(!again.removed_config_file);
+
+ std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR);
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_grok_uses_grok_config_dir_env() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let grok_dir = base.join("custom-grok");
+ fs::create_dir_all(&grok_dir).unwrap();
+ std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir);
+
+ let installed = install_grok().unwrap();
+
+ let hooks_dir = grok_dir.join("hooks");
+ assert_eq!(installed.hook_path, hooks_dir.join(GROK_HOOK_INSTALL_NAME));
+ assert_eq!(
+ installed.config_path,
+ hooks_dir.join(GROK_HOOK_CONFIG_INSTALL_NAME)
+ );
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
fn install_mastracode_errors_when_event_value_not_array() {
let _lock = integration_env_lock();
let base = unique_base();
@@ -3513,3 +3650,146 @@ fn uninstall_mastracode_errors_when_event_value_not_array() {
}
let _ = fs::remove_dir_all(base);
}
+
+#[test]
+fn grok_v1_integration_status_is_current() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let grok_dir = base.join(".grok");
+ fs::create_dir_all(&grok_dir).unwrap();
+ std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir);
+ // A real install writes both the hook script and hooks/herdr.json.
+ install_grok().unwrap();
+
+ let statuses = installed_integration_statuses();
+ let grok = statuses
+ .iter()
+ .find(|status| status.target == crate::api::schema::IntegrationTarget::Grok)
+ .expect("grok integration status");
+ assert_eq!(grok.state, IntegrationStatusKind::Current);
+ assert_eq!(grok.installed_version, Some(GROK_INTEGRATION_VERSION));
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn grok_status_reports_outdated_when_hook_config_missing_or_broken() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let grok_dir = base.join(".grok");
+ fs::create_dir_all(&grok_dir).unwrap();
+ std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &grok_dir);
+ install_grok().unwrap();
+ let config_path = grok_dir.join("hooks").join(GROK_HOOK_CONFIG_INSTALL_NAME);
+
+ let grok_state = || {
+ installed_integration_statuses()
+ .into_iter()
+ .find(|status| status.target == crate::api::schema::IntegrationTarget::Grok)
+ .expect("grok integration status")
+ .state
+ };
+
+ // Missing config: grok never runs the hook, so the install is not current.
+ fs::remove_file(&config_path).unwrap();
+ assert_eq!(grok_state(), IntegrationStatusKind::Outdated);
+
+ // Corrupt config.
+ fs::write(&config_path, "{not json").unwrap();
+ assert_eq!(grok_state(), IntegrationStatusKind::Outdated);
+
+ // Config that no longer references the hook script.
+ fs::write(
+ &config_path,
+ r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo other"}]}]}}"#,
+ )
+ .unwrap();
+ assert_eq!(grok_state(), IntegrationStatusKind::Outdated);
+
+ // Config that mentions the script name without invoking it, and one that
+ // invokes it without the required `session` action: both are
+ // nonfunctional, so neither may report current.
+ fs::write(
+ &config_path,
+ r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo herdr-agent-state.sh"}]}]}}"#,
+ )
+ .unwrap();
+ assert_eq!(grok_state(), IntegrationStatusKind::Outdated);
+ let hook_path = grok_dir.join("hooks").join(GROK_HOOK_INSTALL_NAME);
+ fs::write(
+ &config_path,
+ format!(
+ r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"sh '{}'"}}]}}]}}}}"#,
+ hook_path.display()
+ ),
+ )
+ .unwrap();
+ assert_eq!(grok_state(), IntegrationStatusKind::Outdated);
+
+ // Correct command but not a command-type hook: grok will not execute it.
+ let session_command = grok_session_command(&grok_hook_config(&hook_path));
+ fs::write(
+ &config_path,
+ format!(
+ r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"http","command":{}}}]}}]}}}}"#,
+ serde_json::to_string(&session_command).unwrap()
+ ),
+ )
+ .unwrap();
+ assert_eq!(grok_state(), IntegrationStatusKind::Outdated);
+
+ // A matcher can prevent the expected hook from running.
+ let mut config = grok_hook_config(&hook_path);
+ config["hooks"]["SessionStart"][0]["matcher"] = json!("(");
+ fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
+ assert_eq!(grok_state(), IntegrationStatusKind::Outdated);
+
+ // A malformed sibling group makes grok reject the event's hook groups.
+ let mut config = grok_hook_config(&hook_path);
+ config["hooks"]["SessionStart"]
+ .as_array_mut()
+ .unwrap()
+ .push(json!({}));
+ fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
+ assert_eq!(grok_state(), IntegrationStatusKind::Outdated);
+
+ // Reinstall repairs both files.
+ install_grok().unwrap();
+ assert_eq!(grok_state(), IntegrationStatusKind::Current);
+
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn grok_dir_honors_grok_home_after_config_dir_seam() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home_dir = base.join("grok-home");
+ fs::create_dir_all(&home_dir).unwrap();
+ std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR);
+ std::env::set_var(GROK_HOME_ENV_VAR, &home_dir);
+
+ // The grok CLI reads its config (and hooks/) from $GROK_HOME, so the
+ // integration must install there too.
+ let installed = install_grok().unwrap();
+ assert_eq!(
+ installed.hook_path,
+ home_dir.join("hooks").join(GROK_HOOK_INSTALL_NAME)
+ );
+
+ // The herdr-level test seam still wins over GROK_HOME when set.
+ let seam_dir = base.join("seam");
+ fs::create_dir_all(&seam_dir).unwrap();
+ std::env::set_var(GROK_CONFIG_DIR_ENV_VAR, &seam_dir);
+ let installed = install_grok().unwrap();
+ assert_eq!(
+ installed.hook_path,
+ seam_dir.join("hooks").join(GROK_HOOK_INSTALL_NAME)
+ );
+
+ std::env::remove_var(GROK_HOME_ENV_VAR);
+ clear_integration_path_env();
+ let _ = fs::remove_dir_all(base);
+}