use crate::cron::pipeline::{PipelineExecutor, TriggerOutcome, interpolate_template};
use crate::cron::trigger::{TriggerCondition, TriggerResult, TriggerRunner};
use crate::db::models::CronJob;
#[test]
fn test_trigger_condition_parse() {
assert_eq!(TriggerCondition::parse(None), TriggerCondition::NonEmpty);
assert_eq!(
TriggerCondition::parse(Some("non_empty")),
TriggerCondition::NonEmpty
);
assert_eq!(
TriggerCondition::parse(Some("exit_non_zero")),
TriggerCondition::ExitNonZero
);
assert_eq!(
TriggerCondition::parse(Some("exit_zero")),
TriggerCondition::ExitZero
);
assert_eq!(
TriggerCondition::parse(Some("exitzero")),
TriggerCondition::ExitZero
);
assert_eq!(
TriggerCondition::parse(Some("regex:disk [0-9]+%")),
TriggerCondition::Regex("disk [0-9]+%".into())
);
assert_eq!(
TriggerCondition::parse(Some("re:ERROR.*")),
TriggerCondition::Regex("ERROR.*".into())
);
assert_eq!(
TriggerCondition::parse(Some("always")),
TriggerCondition::Always
);
assert_eq!(
TriggerCondition::parse(Some("unknown_custom")),
TriggerCondition::NonEmpty
);
}
#[test]
fn test_trigger_condition_exit_zero() {
let cond = TriggerCondition::ExitZero;
let res_success = TriggerResult {
stdout: "ok".into(),
stderr: String::new(),
exit_code: 0,
};
assert!(cond.should_fire(&res_success));
let res_failure = TriggerResult {
stdout: String::new(),
stderr: "err".into(),
exit_code: 1,
};
assert!(!cond.should_fire(&res_failure));
}
#[test]
fn test_trigger_condition_regex() {
let cond = TriggerCondition::Regex("alert:\\s*([0-9]+)".into());
let res_match = TriggerResult {
stdout: "alert: 42 anomalies detected".into(),
stderr: String::new(),
exit_code: 0,
};
assert!(cond.should_fire(&res_match));
let res_no_match = TriggerResult {
stdout: "all systems normal".into(),
stderr: String::new(),
exit_code: 0,
};
assert!(!cond.should_fire(&res_no_match));
}
#[test]
fn test_trigger_condition_non_empty() {
let cond = TriggerCondition::NonEmpty;
let res_empty = TriggerResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
};
assert!(!cond.should_fire(&res_empty));
let res_stdout = TriggerResult {
stdout: "found 1 item".into(),
stderr: String::new(),
exit_code: 0,
};
assert!(cond.should_fire(&res_stdout));
let res_stderr = TriggerResult {
stdout: String::new(),
stderr: "warning: disk usage high".into(),
exit_code: 0,
};
assert!(cond.should_fire(&res_stderr));
let res_whitespace_only = TriggerResult {
stdout: " \n\t ".into(),
stderr: String::new(),
exit_code: 0,
};
assert!(!cond.should_fire(&res_whitespace_only));
}
#[test]
fn test_trigger_condition_exit_non_zero() {
let cond = TriggerCondition::ExitNonZero;
let res_zero = TriggerResult {
stdout: "some output".into(),
stderr: String::new(),
exit_code: 0,
};
assert!(!cond.should_fire(&res_zero));
let res_err = TriggerResult {
stdout: String::new(),
stderr: "failure".into(),
exit_code: 1,
};
assert!(cond.should_fire(&res_err));
let res_exit_2 = TriggerResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 2,
};
assert!(cond.should_fire(&res_exit_2));
}
#[test]
fn test_trigger_condition_always() {
let cond = TriggerCondition::Always;
let res_empty = TriggerResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
};
assert!(cond.should_fire(&res_empty));
let res_err = TriggerResult {
stdout: "err".into(),
stderr: String::new(),
exit_code: 127,
};
assert!(cond.should_fire(&res_err));
}
#[test]
fn test_interpolate_template() {
let result = TriggerResult {
stdout: "line 1\nline 2".into(),
stderr: "warn".into(),
exit_code: 0,
};
let tmpl = "Output:\n{output}\nStdout: {stdout}\nStderr: {stderr}\nCode: {exit_code}";
let interpolated = interpolate_template(tmpl, &result);
assert!(interpolated.contains("Output:\nline 1\nline 2\nwarn"));
assert!(interpolated.contains("Stdout: line 1\nline 2"));
assert!(interpolated.contains("Stderr: warn"));
assert!(interpolated.contains("Code: 0"));
}
#[tokio::test]
async fn test_trigger_runner_echo() {
let runner = TriggerRunner::default();
let res = runner.run("echo 'trigger test'").await.expect("run echo");
assert_eq!(res.exit_code, 0);
assert_eq!(res.stdout.trim(), "trigger test");
assert!(res.stderr.is_empty());
}
#[tokio::test]
async fn test_trigger_runner_exit_code() {
let runner = TriggerRunner::default();
let res = runner.run("sh -c 'exit 42'").await.expect("run exit 42");
assert_eq!(res.exit_code, 42);
}
#[tokio::test]
async fn test_pipeline_evaluate_no_trigger() {
let job = CronJob::new(
"no-trigger-job".into(),
"0 0 * * *".into(),
"UTC".into(),
"do something".into(),
None,
None,
"off".into(),
true,
None,
None,
);
match PipelineExecutor::evaluate_trigger(&job).await {
TriggerOutcome::NoTrigger => {}
other => panic!("expected NoTrigger, got {other:?}"),
}
}
#[tokio::test]
async fn test_pipeline_evaluate_fired() {
let job = CronJob::new_with_trigger(
"fired-job".into(),
"0 0 * * *".into(),
"UTC".into(),
"do something".into(),
None,
None,
"off".into(),
true,
None,
None,
Some("echo 'work required'".into()),
Some("non_empty".into()),
false,
None,
);
match PipelineExecutor::evaluate_trigger(&job).await {
TriggerOutcome::Fired(res) => {
assert_eq!(res.stdout.trim(), "work required");
}
other => panic!("expected Fired, got {other:?}"),
}
}
#[tokio::test]
async fn test_pipeline_evaluate_skipped() {
let job = CronJob::new_with_trigger(
"skipped-job".into(),
"0 0 * * *".into(),
"UTC".into(),
"do something".into(),
None,
None,
"off".into(),
true,
None,
None,
Some("true".into()),
Some("non_empty".into()),
false,
None,
);
match PipelineExecutor::evaluate_trigger(&job).await {
TriggerOutcome::Skipped(res) => {
assert!(res.stdout.is_empty());
}
other => panic!("expected Skipped, got {other:?}"),
}
}
#[tokio::test]
async fn test_cron_set_goal_requires_session() {
use crate::brain::tools::cron_manage::CronManageTool;
use crate::brain::tools::r#trait::{Tool, ToolExecutionContext};
use serde_json::json;
let db = crate::db::Database::connect_in_memory().await.unwrap();
db.run_migrations().await.unwrap();
let pool = db.pool().clone();
let repo = crate::db::repository::CronJobRepository::new(pool.clone());
let tool = CronManageTool::new(repo);
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let input_no_deliver = json!({
"action": "create",
"name": "goal-job-1",
"cron": "0 0 * * *",
"tz": "UTC",
"prompt": "do something",
"set_goal": true
});
let res = tool.execute(input_no_deliver, &ctx).await.unwrap();
assert!(!res.success, "set_goal without deliver_to must be rejected");
assert!(
res.error
.unwrap_or_default()
.contains("set_goal requires oc://session"),
"error message must explain set_goal requires session target"
);
let input_channel_deliver = json!({
"action": "create",
"name": "goal-job-2",
"cron": "0 0 * * *",
"tz": "UTC",
"prompt": "do something",
"deliver_to": "https://example.com/webhook",
"set_goal": true
});
let res2 = tool.execute(input_channel_deliver, &ctx).await.unwrap();
assert!(
!res2.success,
"set_goal with channel delivery must be rejected"
);
assert!(
res2.error
.unwrap_or_default()
.contains("set_goal requires oc://session"),
"error message must explain channel delivery is passive"
);
let input_session_deliver = json!({
"action": "create",
"name": "goal-job-3",
"cron": "0 0 * * *",
"tz": "UTC",
"prompt": "do something",
"deliver_to": "oc://session/12345678-1234-1234-1234-123456789abc",
"set_goal": true
});
let res3 = tool.execute(input_session_deliver, &ctx).await.unwrap();
assert!(
res3.success,
"set_goal with session delivery must succeed: {:?}",
res3.error
);
}
#[tokio::test]
async fn test_trigger_runner_captures_stderr() {
let runner = TriggerRunner::default();
let res = runner
.run("echo 'err' >&2; exit 2")
.await
.expect("run stderr probe");
assert_eq!(res.exit_code, 2);
assert_eq!(res.stderr.trim(), "err");
assert!(res.stdout.trim().is_empty());
assert!(TriggerCondition::ExitNonZero.should_fire(&res));
}
#[cfg(unix)]
#[tokio::test]
async fn test_timed_out_trigger_kills_its_shell() {
use std::time::Duration;
let marker = format!("opencrabs_trigger_orphan_probe_{}", std::process::id());
let runner = TriggerRunner::new(Duration::from_millis(200));
let err = runner
.run(&format!("sleep 5; : # {marker}"))
.await
.expect_err("a 5s sleep must outrun a 200ms timeout");
assert!(err.contains("timed out"), "unexpected error: {err}");
tokio::time::sleep(Duration::from_millis(500)).await;
let survivors = std::process::Command::new("pgrep")
.arg("-f")
.arg(&marker)
.output()
.expect("pgrep");
let found = String::from_utf8_lossy(&survivors.stdout);
assert!(
found.trim().is_empty(),
"timed-out trigger left its shell running (pids: {})",
found.trim()
);
}
#[test]
fn test_fired_prompt_substitutes_all_placeholders() {
let res = TriggerResult {
stdout: "line 1\nline 2".into(),
stderr: "warn".into(),
exit_code: 0,
};
assert_eq!(
interpolate_template("out={stdout}|err={stderr}|code={exit_code}", &res),
"out=line 1\nline 2|err=warn|code=0"
);
let killed = TriggerResult {
stdout: String::new(),
stderr: String::new(),
exit_code: -1,
};
assert_eq!(interpolate_template("c={exit_code}", &killed), "c=-1");
}
#[test]
fn test_fired_prompt_without_placeholder_is_byte_identical() {
let res = TriggerResult {
stdout: "line 1\nline 2".into(),
stderr: "warn".into(),
exit_code: 0,
};
let prompt = "plain prompt with no placeholders at all";
let out = interpolate_template(prompt, &res);
assert_eq!(out, prompt);
assert_eq!(out.len(), prompt.len(), "length must be unchanged");
assert_eq!(out.as_bytes(), prompt.as_bytes(), "bytes must be identical");
}
#[test]
fn test_fired_prompt_output_then_stdout_is_a_sequential_chain() {
let res = TriggerResult {
stdout: "S{stdout}T".into(),
stderr: String::new(),
exit_code: 0,
};
assert_eq!(interpolate_template("P{output}Q", &res), "PSS{stdout}TTQ");
}
#[test]
fn test_fired_prompt_large_payload_without_placeholder_is_byte_identical() {
let big = "x".repeat(5000);
let res = TriggerResult {
stdout: big.clone(),
stderr: String::new(),
exit_code: 0,
};
let prompt = "no placeholder here";
assert_eq!(big.len(), 5000);
assert_eq!(interpolate_template(prompt, &res), prompt);
}
#[test]
fn test_fired_prompt_empty_payload_substitutes_the_empty_string() {
let res = TriggerResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
};
assert_eq!(interpolate_template("a{stdout}b", &res), "ab");
}
#[test]
fn test_fired_prompt_output_join_rule() {
let both = TriggerResult {
stdout: "L1".into(),
stderr: "L2".into(),
exit_code: 0,
};
let stdout_only = TriggerResult {
stdout: "L1".into(),
stderr: String::new(),
exit_code: 0,
};
let stderr_only = TriggerResult {
stdout: String::new(),
stderr: "L2".into(),
exit_code: 0,
};
assert_eq!(interpolate_template("o={output}", &both), "o=L1\nL2");
assert_eq!(interpolate_template("o={output}", &stdout_only), "o=L1");
assert_eq!(interpolate_template("o={output}", &stderr_only), "o=L2");
}
#[test]
fn test_non_fired_outcomes_never_build_a_prompt() {
const SRC: &str = include_str!("../cron/scheduler.rs");
const SKIPPED: &str = "TriggerOutcome::Skipped(ref trig_res) =>";
const ERROR: &str = "TriggerOutcome::Error(err) =>";
const FIRED: &str = "TriggerOutcome::Fired(ref trig_res) =>";
const NO_TRIGGER: &str = "TriggerOutcome::NoTrigger =>";
fn arm<'a>(src: &'a str, from: &str, to: &str) -> &'a str {
let start = src
.find(from)
.unwrap_or_else(|| panic!("arm `{from}` not found — did the match move?"));
let rest = &src[start..];
let end = rest
.find(to)
.unwrap_or_else(|| panic!("arm `{to}` not found after `{from}` — did the match move?"));
&rest[..end]
}
assert!(
!arm(SRC, SKIPPED, ERROR).contains("fired_prompt"),
"the Skipped arm must not build a substituted prompt — nothing fired"
);
assert!(
!arm(SRC, ERROR, FIRED).contains("fired_prompt"),
"the Error arm must not build a substituted prompt — nothing fired"
);
assert!(
!arm(SRC, NO_TRIGGER, "resolve_or_create_cron_session").contains("fired_prompt"),
"the NoTrigger arm must not build a substituted prompt — nothing fired"
);
assert!(
arm(SRC, FIRED, NO_TRIGGER).contains("fired_prompt"),
"the Fired arm must build the substituted prompt — that is the fix"
);
assert!(
SRC.contains("fired_prompt.unwrap_or(job.prompt.as_str())"),
"the None path must fall back to the job's own prompt — the turn message \
is byte-identical when nothing fired"
);
}
#[test]
fn test_agent_turn_message_is_built_through_the_trigger_helper() {
const SCHEDULER_SRC: &str = include_str!("../cron/scheduler.rs");
assert!(SCHEDULER_SRC.contains("interpolate_template("));
assert!(SCHEDULER_SRC.contains("fired_prompt"));
assert!(SCHEDULER_SRC.contains("fired_prompt.as_deref()"));
assert!(SCHEDULER_SRC.contains("turn_prompt"));
assert!(
!SCHEDULER_SRC.contains("job.prompt.clone()"),
"#526 REGRESSION: the turn message is built from the raw job prompt again — \
the fired trigger's payload is being dropped"
);
assert!(!SCHEDULER_SRC.contains("match &outcome"));
}