use super::*;
use tempfile::tempdir;
fn utc(y: i32, m: u32, d: u32, hh: u32, mm: u32, ss: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, hh, mm, ss).single().expect("valid timestamp")
}
#[test]
fn cron5_supports_vixie_or_semantics() {
let cron = Cron5::parse("0 9 15 * 1").expect("cron");
let monday = Local.with_ymd_and_hms(2026, 3, 30, 9, 0, 0).single().expect("monday");
let dom = Local.with_ymd_and_hms(2026, 4, 15, 9, 0, 0).single().expect("dom");
assert!(cron.parsed().expect("parsed").matches(monday));
assert!(cron.parsed().expect("parsed").matches(dom));
}
#[test]
fn cron5_rejects_extended_syntax() {
Cron5::parse("0 9 ? * *").unwrap_err();
Cron5::parse("0 9 * JAN *").unwrap_err();
Cron5::parse("0 9 * * MON").unwrap_err();
}
#[test]
fn cron5_finds_next_matching_minute() {
let cron = Cron5::parse("*/15 * * * *").expect("cron");
let start = Local.with_ymd_and_hms(2026, 3, 28, 10, 7, 13).single().expect("start");
let next = cron.next_after(start).expect("next").expect("some");
assert_eq!(next.minute(), 15);
}
#[test]
fn reminder_language_detects_at_time() {
let now = Local.with_ymd_and_hms(2026, 3, 28, 13, 0, 0).single().expect("now");
let command = parse_session_language_command("remind me at 3pm to push the release branch", now)
.expect("command")
.expect("parsed");
match command {
SessionLanguageCommand::CreateOneShotPrompt { prompt, .. } => {
assert_eq!(prompt, "push the release branch");
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn reminder_language_detects_relative_time() {
let now = Local.with_ymd_and_hms(2026, 3, 28, 13, 0, 0).single().expect("now");
let command = parse_session_language_command("in 45 minutes, check whether the integration tests passed", now)
.expect("command")
.expect("parsed");
match command {
SessionLanguageCommand::CreateOneShotPrompt { prompt, run_at } => {
assert_eq!(prompt, "check whether the integration tests passed");
assert_eq!(run_at, (now + ChronoDuration::minutes(45)).with_timezone(&Utc));
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn session_scheduler_expires_recurring_tasks_after_final_fire() {
let created_at = utc(2026, 3, 28, 0, 0, 0);
let mut scheduler = SessionScheduler::new();
scheduler
.create_prompt_task(
Some("heartbeat".to_string()),
"check".to_string(),
ScheduleSpec::FixedInterval(FixedInterval { seconds: 60 * 60 }),
created_at,
)
.expect("create");
let record = scheduler.tasks.values_mut().next().expect("task");
record.runtime.next_base_run_at = Some(created_at + ChronoDuration::hours(72));
record.runtime.next_run_at = Some(created_at + ChronoDuration::hours(72));
let due = scheduler
.collect_due_prompts(created_at + ChronoDuration::hours(72))
.expect("collect");
assert_eq!(due.len(), 1);
assert!(scheduler.is_empty());
}
#[test]
fn session_scheduler_jitter_is_stable_for_task_id() {
let definition = ScheduledTaskDefinition {
id: "abcd1234".to_string(),
name: "test".to_string(),
schedule: ScheduleSpec::FixedInterval(FixedInterval { seconds: 600 }),
action: ScheduledTaskAction::Prompt { prompt: "check".to_string() },
workspace: None,
created_at: utc(2026, 3, 28, 0, 0, 0),
expires_at: None,
};
let base = utc(2026, 3, 28, 1, 0, 0);
let first = definition.schedule.jittered_fire_at(&definition.id, base).expect("jitter");
let second = definition.schedule.jittered_fire_at(&definition.id, base).expect("jitter");
assert_eq!(first, second);
}
#[test]
fn disable_cron_env_overrides_enabled_config() {
test_env_overrides::set(Some("1"));
assert!(!scheduled_tasks_enabled(true));
test_env_overrides::set(None);
}
#[test]
fn durable_store_creates_and_lists_tasks() {
let temp = tempdir().expect("tempdir");
let store = DurableTaskStore::with_paths(SchedulerPaths {
config_root: temp.path().join("cfg"),
data_root: temp.path().join("data"),
});
let definition = ScheduledTaskDefinition::new(
Some("daily".to_string()),
ScheduleSpec::OneShot(OneShot { at: utc(2026, 3, 29, 9, 0, 0) }),
ScheduledTaskAction::Reminder { message: "check release".to_string() },
None,
utc(2026, 3, 28, 0, 0, 0),
None,
)
.expect("definition");
store.create(definition).expect("create");
let tasks = store.list().expect("list");
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "daily");
}
#[test]
fn scheduled_workspace_resolution_expands_home_and_normalizes() {
let resolved = resolve_scheduled_workspace_path_with_home(
Path::new("~/projects/demo/../vtcode"),
Some(Path::new("/tmp/home")),
)
.expect("resolve");
assert_eq!(resolved, PathBuf::from("/tmp/home/projects/vtcode"));
}
#[test]
fn schedule_create_definition_normalizes_prompt_workspace() {
let temp = tempdir().expect("tempdir");
let workspace = temp.path().join("workspace");
fs::create_dir_all(&workspace).expect("workspace");
let definition = ScheduleCreateInput {
name: None,
prompt: Some("check build".to_string()),
reminder: None,
every: Some("15m".to_string()),
cron: None,
at: None,
workspace: Some(workspace.join(".").join("nested").join("..")),
}
.build_definition(Local::now(), None)
.expect("definition");
assert_eq!(definition.workspace.as_deref(), Some(workspace.as_path()));
}
#[test]
fn schedule_create_definition_rejects_missing_prompt_workspace() {
let error = ScheduleCreateInput {
name: None,
prompt: Some("check build".to_string()),
reminder: None,
every: Some("15m".to_string()),
cron: None,
at: None,
workspace: Some(PathBuf::from("/path/that/does/not/exist")),
}
.build_definition(Local::now(), None)
.expect_err("missing workspace should fail");
assert!(
error
.to_string()
.contains("Prompt task workspace does not exist or is not a directory")
);
}
#[cfg(unix)]
#[tokio::test]
async fn scheduler_daemon_executes_due_prompt_task() {
let temp = tempdir().expect("tempdir");
let workspace = temp.path().join("workspace");
fs::create_dir_all(&workspace).expect("workspace");
let store = DurableTaskStore::with_paths(SchedulerPaths {
config_root: temp.path().join("cfg"),
data_root: temp.path().join("data"),
});
let now = Utc::now();
let definition = ScheduledTaskDefinition::new(
Some("hello".to_string()),
ScheduleSpec::OneShot(OneShot { at: now - ChronoDuration::minutes(1) }),
ScheduledTaskAction::Prompt { prompt: "say hello".to_string() },
Some(workspace),
now - ChronoDuration::minutes(2),
None,
)
.expect("definition");
let summary = store.create(definition).expect("create");
let executable = ["/usr/bin/true", "/bin/true"]
.into_iter()
.map(PathBuf::from)
.find(|path| path.exists())
.expect("true executable");
let daemon = SchedulerDaemon::new(store.clone(), executable);
let executed = daemon.run_due_tasks_once().await.expect("run");
assert_eq!(executed, 1);
let record = store.load_record(&summary.id).expect("load").expect("record");
assert!(record.runtime.last_run_at.is_some());
assert!(record.runtime.next_run_at.is_none());
assert_eq!(record.runtime.last_status.as_ref().map(ToString::to_string), Some("success".to_string()));
assert!(record.runtime.last_artifact_dir.is_some());
}
#[cfg(unix)]
#[tokio::test]
async fn scheduler_daemon_records_prompt_spawn_failures() {
let temp = tempdir().expect("tempdir");
let missing_workspace = temp.path().join("missing-workspace");
let store = DurableTaskStore::with_paths(SchedulerPaths {
config_root: temp.path().join("cfg"),
data_root: temp.path().join("data"),
});
let now = Utc::now();
let definition = ScheduledTaskDefinition::new(
Some("broken".to_string()),
ScheduleSpec::OneShot(OneShot { at: now - ChronoDuration::minutes(1) }),
ScheduledTaskAction::Prompt { prompt: "say hello".to_string() },
Some(missing_workspace),
now - ChronoDuration::minutes(2),
None,
)
.expect("definition");
let summary = store.create(definition).expect("create");
let executable = ["/usr/bin/true", "/bin/true"]
.into_iter()
.map(PathBuf::from)
.find(|path| path.exists())
.expect("true executable");
let daemon = SchedulerDaemon::new(store.clone(), executable);
let executed = daemon.run_due_tasks_once().await.expect("run");
assert_eq!(executed, 1);
let record = store.load_record(&summary.id).expect("load").expect("record");
assert!(record.runtime.last_run_at.is_some());
assert!(record.runtime.next_run_at.is_none());
assert!(matches!(record.runtime.last_status, Some(TaskRunStatus::Failed { .. })));
}
#[test]
fn durable_task_overdue_detection_requires_due_unrun_task() {
let now = utc(2026, 3, 29, 0, 30, 0);
assert!(durable_task_is_overdue(Some(utc(2026, 3, 29, 0, 22, 47)), None, false, now));
assert!(!durable_task_is_overdue(Some(utc(2026, 3, 29, 0, 40, 0)), None, false, now));
assert!(!durable_task_is_overdue(
Some(utc(2026, 3, 29, 0, 22, 47)),
Some(utc(2026, 3, 29, 0, 22, 47)),
false,
now
));
assert!(!durable_task_is_overdue(Some(utc(2026, 3, 29, 0, 22, 47)), None, true, now));
}
#[test]
fn service_rendering_mentions_schedule_serve() {
let launchd = render_launchd_plist(Path::new("/usr/local/bin/vtcode"));
assert!(launchd.contains("schedule"));
assert!(launchd.contains("serve"));
let systemd = render_systemd_unit(Path::new("/usr/local/bin/vtcode"));
assert!(systemd.contains("schedule serve"));
}
#[test]
fn schedule_create_arg_parser_supports_workspace() {
let parsed =
parse_schedule_create_args(r#"--prompt "check build" --every 15m --workspace /tmp/demo --name "Build watch""#)
.expect("parse");
assert_eq!(parsed.name.as_deref(), Some("Build watch"));
assert_eq!(parsed.prompt.as_deref(), Some("check build"));
assert_eq!(parsed.every.as_deref(), Some("15m"));
assert_eq!(parsed.workspace, Some(PathBuf::from("/tmp/demo")));
}