From 937f1da7e2690d9612b169dcbae6c79aef6aa9e4 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 27 Mar 2026 21:47:14 +0000 Subject: [PATCH 1/4] refactor: enhance task scheduling with flexible time options --- schtasks/task.go | 37 +++++++----- schtasks/task_options.go | 21 +++++++ schtasks/taskscheduler.go | 12 ++-- schtasks/taskscheduler_test.go | 60 +++++++++++++++++++- schtasks/trigger.go | 8 ++- schtasks/trigger_test.go | 101 +++++++++++++++++++++++++-------- 6 files changed, 194 insertions(+), 45 deletions(-) create mode 100644 schtasks/task_options.go diff --git a/schtasks/task.go b/schtasks/task.go index e11a6fcb..26d60df2 100644 --- a/schtasks/task.go +++ b/schtasks/task.go @@ -30,9 +30,10 @@ type Task struct { Principals Principals `xml:"Principals"` Settings Settings `xml:"Settings"` Actions Actions `xml:"Actions"` + fromNow time.Time `xml:"-"` } -func NewTask() Task { +func NewTask(options ...TaskOption) Task { var userID string if currentUser, err := user.Current(); err == nil { userID = currentUser.Uid @@ -69,6 +70,11 @@ func NewTask() Task { Actions: Actions{ Context: author, }, + fromNow: time.Now(), + } + + for _, option := range options { + option.apply(&task) } return task } @@ -105,9 +111,14 @@ func (t *Task) AddSchedules(schedules []*calendar.Event) { } } +func (t *Task) setFromNow(fromNow time.Time) { + t.fromNow = fromNow + t.RegistrationInfo.Date = fromNow.Format(dateFormat) +} + func (t *Task) addTimeTrigger(triggerOnce time.Time) { timeTrigger := TimeTrigger{ - StartBoundary: triggerOnce.Format(dateFormat), + StartBoundary: &triggerOnce, } if t.Triggers.TimeTrigger == nil { t.Triggers.TimeTrigger = []TimeTrigger{timeTrigger} @@ -125,7 +136,7 @@ func (t *Task) addCalendarTrigger(trigger CalendarTrigger) { } func (t *Task) addDailyTrigger(schedule *calendar.Event) { - start := schedule.Next(time.Now()) + start := schedule.Next(t.fromNow) // get all recurrences in the same day recurrences := schedule.GetAllInBetween(start, start.Add(24*time.Hour)) if len(recurrences) == 0 { @@ -135,7 +146,7 @@ func (t *Task) addDailyTrigger(schedule *calendar.Event) { // Is it only once a day? if len(recurrences) == 1 { t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: recurrences[0].Format(dateFormat), + StartBoundary: &recurrences[0], ScheduleByDay: &ScheduleByDay{ DaysInterval: 1, }, @@ -149,7 +160,7 @@ func (t *Task) addDailyTrigger(schedule *calendar.Event) { // case with regular repetition interval := period.NewOf(compactDifferences[0]) t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: start.Format(dateFormat), + StartBoundary: &start, ScheduleByDay: &ScheduleByDay{ DaysInterval: 1, }, @@ -168,7 +179,7 @@ func (t *Task) addDailyTrigger(schedule *calendar.Event) { // install them all for _, recurrence := range recurrences { t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: recurrence.Format(dateFormat), + StartBoundary: &recurrence, ScheduleByDay: &ScheduleByDay{ DaysInterval: 1, }, @@ -177,7 +188,7 @@ func (t *Task) addDailyTrigger(schedule *calendar.Event) { } func (t *Task) addWeeklyTrigger(schedule *calendar.Event) { - start := schedule.Next(time.Now()) + start := schedule.Next(t.fromNow) // get all recurrences in the same day recurrences := schedule.GetAllInBetween(start, start.Add(24*time.Hour)) if len(recurrences) == 0 { @@ -187,7 +198,7 @@ func (t *Task) addWeeklyTrigger(schedule *calendar.Event) { // Is it only once per 24h? if len(recurrences) == 1 { t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: recurrences[0].Format(dateFormat), + StartBoundary: &recurrences[0], ScheduleByWeek: &ScheduleByWeek{ WeeksInterval: 1, DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()), @@ -202,7 +213,7 @@ func (t *Task) addWeeklyTrigger(schedule *calendar.Event) { // case with regular repetition interval := period.NewOf(compactDifferences[0]) t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: start.Format(dateFormat), + StartBoundary: &start, ScheduleByWeek: &ScheduleByWeek{ WeeksInterval: 1, DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()), @@ -222,7 +233,7 @@ func (t *Task) addWeeklyTrigger(schedule *calendar.Event) { // install them all for _, recurrence := range recurrences { t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: recurrence.Format(dateFormat), + StartBoundary: &recurrence, ScheduleByWeek: &ScheduleByWeek{ WeeksInterval: 1, DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()), @@ -232,7 +243,7 @@ func (t *Task) addWeeklyTrigger(schedule *calendar.Event) { } func (t *Task) addMonthlyTrigger(schedule *calendar.Event) { - start := schedule.Next(time.Now()) + start := schedule.Next(t.fromNow) // get all recurrences in the same day recurrences := schedule.GetAllInBetween(start, start.Add(24*time.Hour)) if len(recurrences) == 0 { @@ -252,7 +263,7 @@ func (t *Task) addMonthlyTrigger(schedule *calendar.Event) { } if schedule.WeekDay.HasValue() { t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: recurrence.Format(dateFormat), + StartBoundary: &recurrence, ScheduleByMonthDayOfWeek: &ScheduleByMonthDayOfWeek{ DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()), Weeks: AllWeeks, @@ -262,7 +273,7 @@ func (t *Task) addMonthlyTrigger(schedule *calendar.Event) { continue } t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: recurrence.Format(dateFormat), + StartBoundary: &recurrence, ScheduleByMonth: &ScheduleByMonth{ DaysOfMonth: convertDaysOfMonth(schedule.Day.GetRangeValues()), Months: convertMonths(schedule.Month.GetRangeValues()), diff --git a/schtasks/task_options.go b/schtasks/task_options.go new file mode 100644 index 00000000..b9c1905f --- /dev/null +++ b/schtasks/task_options.go @@ -0,0 +1,21 @@ +//go:build windows + +package schtasks + +import "time" + +type TaskOption interface { + apply(t *Task) +} + +type WithFromNowOption struct { + now time.Time +} + +func WithFromNow(now time.Time) WithFromNowOption { + return WithFromNowOption{now: now} +} + +func (w WithFromNowOption) apply(t *Task) { + t.setFromNow(w.now) +} diff --git a/schtasks/taskscheduler.go b/schtasks/taskscheduler.go index 9de6edd1..99a8867f 100644 --- a/schtasks/taskscheduler.go +++ b/schtasks/taskscheduler.go @@ -28,6 +28,7 @@ import ( "slices" "strings" "text/tabwriter" + "time" "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" @@ -53,8 +54,7 @@ func Create(config *Config, schedules []*calendar.Event, permission Permission) return fmt.Errorf("cannot delete existing task to replace it: %w", err) } } - - task := createTaskDefinition(config, schedules) + task := createTaskDefinition(config, schedules, time.Time{}) task.RegistrationInfo.URI = taskPath switch config.RunLevel { @@ -181,8 +181,12 @@ func getTaskPath(profileName, commandName string) string { return fmt.Sprintf("%s%s %s", tasksPathPrefix, profileName, commandName) } -func createTaskDefinition(config *Config, schedules []*calendar.Event) Task { - task := NewTask() +func createTaskDefinition(config *Config, schedules []*calendar.Event, from time.Time) Task { + options := make([]TaskOption, 0, 1) + if !from.IsZero() { + options = append(options, WithFromNow(from)) + } + task := NewTask(options...) task.RegistrationInfo.Description = config.JobDescription task.Settings.StartWhenAvailable = config.StartWhenAvailable task.AddExecAction(ExecAction{ diff --git a/schtasks/taskscheduler_test.go b/schtasks/taskscheduler_test.go index fd656c77..f1a061c2 100644 --- a/schtasks/taskscheduler_test.go +++ b/schtasks/taskscheduler_test.go @@ -125,98 +125,131 @@ func TestTaskSchedulerIntegration(t *testing.T) { fixtures := []struct { description string schedules []string + fromNow time.Time }{ { "only once", []string{"2020-01-02 03:04"}, + time.Time{}, }, // daily { "once every day", []string{"*-*-* 03:04"}, + time.Time{}, }, { "every hour", []string{"*-*-* *:04"}, + time.Time{}, }, { "every minute", []string{"*-*-* *:*"}, + time.Time{}, }, { - "every minute at 12", + "every minute at 12 (before 12)", []string{"*-*-* 12:*"}, + time.Date(2025, 7, 27, 11, 20, 0, 0, time.UTC), + }, + // this creates 60 triggers + // { + // "every minute at 12", + // []string{"*-*-* 12:*"}, + // time.Date(2025, 7, 27, 12, 20, 0, 0, time.UTC), + // }, + { + "every minute at 12 (after 12)", + []string{"*-*-* 12:*"}, + time.Date(2025, 7, 27, 13, 20, 0, 0, time.UTC), }, // daily - more than one { "three times a day", []string{"*-*-* 03..05:04"}, + time.Time{}, }, { "twice every hour", []string{"*-*-* *:04..05"}, + time.Time{}, }, // weekly { "once weekly", []string{"mon *-*-* 03:04"}, + time.Time{}, }, { "every hour on mondays", []string{strings.ToLower(fixedDay)[:3] + " *-*-* *:04"}, + time.Time{}, }, { "every minute on mondays", []string{strings.ToLower(fixedDay)[:3] + " *-*-* *:*"}, + time.Time{}, }, { "every minute at 12 on mondays", []string{"mon *-*-* 12:*"}, + time.Time{}, }, // more than once weekly { "twice weekly", []string{"mon *-*-* 03..04:04"}, + time.Time{}, }, { "twice mondays and tuesdays", []string{"mon,tue *-*-* 03:04..06"}, + time.Time{}, }, { "twice on fridays", []string{"fri *-*-* *:04..05"}, + time.Time{}, }, // monthly { "once monthly", []string{"*-01-* 03:04"}, + time.Time{}, }, { "every hour in january", []string{"*-01-* *:04"}, + time.Time{}, }, // monthly with weekdays { "mondays in January", []string{"mon *-01-* 03:04"}, + time.Time{}, }, { "every hour on Mondays in january", []string{"mon *-01-* *:04"}, + time.Time{}, }, // some days every month { "one day per month", []string{"*-*-0" + dayOfTheMonth + " 03:04"}, + time.Time{}, }, { "every hour on the 1st of each month", []string{"*-*-0" + dayOfTheMonth + " *:04"}, + time.Time{}, }, // more than once per month { "twice in one day per month", []string{"*-*-0" + dayOfTheMonth + " 03..04:04"}, + time.Time{}, }, } @@ -247,13 +280,15 @@ func TestTaskSchedulerIntegration(t *testing.T) { defer file.Close() taskPath := getTaskPath(config.ProfileName, config.CommandName) - sourceTask := createTaskDefinition(config, schedules) + sourceTask := createTaskDefinition(config, schedules, fixture.fromNow) sourceTask.RegistrationInfo.URI = taskPath err = createTaskFile(sourceTask, file) require.NoError(t, err) file.Close() + t.Logf("task contains %d time triggers and %d calendar triggers", len(sourceTask.Triggers.TimeTrigger), len(sourceTask.Triggers.CalendarTrigger)) + result, err := createTask(taskPath, file.Name(), "", "") t.Log(result) require.NoError(t, err) @@ -271,6 +306,9 @@ func TestTaskSchedulerIntegration(t *testing.T) { err = decoder.Decode(&readTask) require.NoError(t, err) + sourceTask.fromNow = time.Time{} // ignore fromNow in the source task + taskInUTC(&sourceTask) + taskInUTC(readTask) assert.Equal(t, sourceTask, *readTask) result, err = deleteTask(taskPath) @@ -341,3 +379,21 @@ func TestStartWhenAvailableOption(t *testing.T) { assert.True(t, readTask.Settings.StartWhenAvailable, "StartWhenAvailable should be true in the created task") } + +func taskInUTC(task *Task) { + // Windows Task Scheduler is using the current timezone when loading dates into the XML definition. + // This is a workaround to ensure that the tests run consistently. + for i := range task.Triggers.TimeTrigger { + if task.Triggers.TimeTrigger[i].StartBoundary != nil { + *task.Triggers.TimeTrigger[i].StartBoundary = task.Triggers.TimeTrigger[i].StartBoundary.UTC() + } + } + for i := range task.Triggers.CalendarTrigger { + if task.Triggers.CalendarTrigger[i].StartBoundary != nil { + *task.Triggers.CalendarTrigger[i].StartBoundary = task.Triggers.CalendarTrigger[i].StartBoundary.UTC() + } + if task.Triggers.CalendarTrigger[i].EndBoundary != nil { + *task.Triggers.CalendarTrigger[i].EndBoundary = task.Triggers.CalendarTrigger[i].EndBoundary.UTC() + } + } +} diff --git a/schtasks/trigger.go b/schtasks/trigger.go index e855f314..d28a4d04 100644 --- a/schtasks/trigger.go +++ b/schtasks/trigger.go @@ -3,6 +3,8 @@ package schtasks import ( + "time" + "github.com/rickb777/period" ) @@ -13,14 +15,14 @@ type Triggers struct { type TimeTrigger struct { Enabled *bool `xml:"Enabled"` // indicates whether the trigger is enabled - StartBoundary string `xml:"StartBoundary"` + StartBoundary *time.Time `xml:"StartBoundary"` ExecutionTimeLimit *period.Period `xml:"ExecutionTimeLimit"` RandomDelay *period.Period `xml:"RandomDelay,omitempty"` // a delay time that is randomly added to the start time of the trigger } type CalendarTrigger struct { - StartBoundary string `xml:"StartBoundary,omitempty"` // the date and time when the trigger is activated - EndBoundary string `xml:"EndBoundary,omitempty"` // the date and time when the trigger is deactivated + StartBoundary *time.Time `xml:"StartBoundary,omitempty"` // the date and time when the trigger is activated + EndBoundary *time.Time `xml:"EndBoundary,omitempty"` // the date and time when the trigger is deactivated Repetition *RepetitionPattern `xml:"Repetition"` ExecutionTimeLimit *period.Period `xml:"ExecutionTimeLimit"` // the maximum amount of time that the task launched by this trigger is allowed to run Enabled *bool `xml:"Enabled"` // indicates whether the trigger is enabled diff --git a/schtasks/trigger_test.go b/schtasks/trigger_test.go index 76acb60f..7ff269c9 100644 --- a/schtasks/trigger_test.go +++ b/schtasks/trigger_test.go @@ -32,145 +32,183 @@ func TestTriggerCreationFromXML(t *testing.T) { fixedDay = "Tuesday" } + timezone := `(Z|[+-]\d{2}:\d{2})` + fixtures := []struct { description string schedules []string expected string expectedMatchCount int + from time.Time }{ { "only once", []string{"2020-01-02 03:04"}, - `\s*2020-01-02T03:04:00\+00:00\s*`, + `\s*2020-01-02T03:04:00` + timezone + `\s*`, 1, + time.Time{}, }, // daily { "once every day", []string{"*-*-* 03:04"}, - `\s*\d{4}-\d{2}-\d{2}T03:04:00\+00:00\s*\s*1\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T03:04:00` + timezone + `\s*\s*1\s*\s*`, 1, + time.Time{}, }, { "every hour", []string{"*-*-* *:04"}, - `\s*\d{4}-\d{2}-\d{2}T\d{2}:04:00\+00:00\s*\s*PT1H\s*PT23H\s*\s*\s*1\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T\d{2}:04:00` + timezone + `\s*\s*PT1H\s*PT23H\s*\s*\s*1\s*\s*`, 1, + time.Time{}, }, { "every minute", []string{"*-*-* *:*"}, - `\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00\+00:00\s*\s*PT1M\s*P1D\s*\s*\s*1\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00` + timezone + `\s*\s*PT1M\s*P1D\s*\s*\s*1\s*\s*`, 1, + time.Time{}, }, + // { + // "every minute at 12 (before 12)", + // []string{"*-*-* 12:*"}, + // `\s*\d{4}-\d{2}-\d{2}T11:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, + // 1, + // time.Date(2025, 7, 27, 11, 20, 0, 0, time.UTC), + // }, + // { + // "every minute at 12", + // []string{"*-*-* 12:*"}, + // `\s*\d{4}-\d{2}-\d{2}T12:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, + // 1, + // time.Date(2025, 7, 27, 12, 20, 0, 0, time.UTC), + // }, { - "every minute at 12", + "every minute at 12 (after 12)", []string{"*-*-* 12:*"}, - `\s*\d{4}-\d{2}-\d{2}T12:\d{2}:00\+00:00\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T13:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, 1, + time.Date(2025, 7, 27, 13, 20, 0, 0, time.UTC), }, // daily - more than one { "three times a day", []string{"*-*-* 03..05:04"}, - `\s*\d{4}-\d{2}-\d{2}T03:04:00\+00:00\s*\s*PT1H\s*PT2H\s*\s*\s*1\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T03:04:00` + timezone + `\s*\s*PT1H\s*PT2H\s*\s*\s*1\s*\s*`, 1, + time.Time{}, }, { "twice every hour", []string{"*-*-* *:04..05"}, - `\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00\+00:00\s*\s*1\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00` + timezone + `\s*\s*1\s*\s*`, 48, + time.Time{}, }, // weekly { "once weekly", []string{"mon *-*-* 03:04"}, - `\s*\d{4}-\d{2}-\d{2}T03:04:00\+00:00\s*\s*1\s*\s*\s*\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T03:04:00` + timezone + `\s*\s*1\s*\s*\s*\s*\s*`, 1, + time.Time{}, }, { "every hour on mondays", []string{strings.ToLower(fixedDay)[:3] + " *-*-* *:04"}, - `\s*\d{4}-\d{2}-\d{2}T\d{2}:04:00\+00:00\s*\s*PT1H\s*PT23H\s*\s*\s*1\s*\s*<` + fixedDay + `>\s*\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T\d{2}:04:00` + timezone + `\s*\s*PT1H\s*PT23H\s*\s*\s*1\s*\s*<` + fixedDay + `>\s*\s*\s*`, 1, + time.Time{}, }, { "every minute on mondays", []string{strings.ToLower(fixedDay)[:3] + " *-*-* *:*"}, - `\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00\+00:00\s*\s*PT1M\s*P1D\s*\s*\s*1\s*\s*<` + fixedDay + `>\s*\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00` + timezone + `\s*\s*PT1M\s*P1D\s*\s*\s*1\s*\s*<` + fixedDay + `>\s*\s*\s*`, 1, + time.Time{}, }, { "every minute at 12 on mondays", []string{"mon *-*-* 12:*"}, - `\s*\d{4}-\d{2}-\d{2}T12:\d{2}:00\+00:00\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*\s*\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T12:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*\s*\s*\s*`, 1, + time.Time{}, }, // more than once weekly { "twice weekly", []string{"mon *-*-* 03..04:04"}, - `\s*\d{4}-\d{2}-\d{2}T03:04:00\+00:00\s*\s*PT1H\s*PT1H\s*\s*\s*1\s*\s*\s*\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T03:04:00` + timezone + `\s*\s*PT1H\s*PT1H\s*\s*\s*1\s*\s*\s*\s*\s*`, 1, + time.Time{}, }, { "twice mondays and tuesdays", []string{"mon,tue *-*-* 03:04..06"}, - `\s*\d{4}-\d{2}-\d{2}T03:04:00\+00:00\s*\s*PT1M\s*PT2M\s*\s*\s*1\s*\s*\s*\s*\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T03:04:00` + timezone + `\s*\s*PT1M\s*PT2M\s*\s*\s*1\s*\s*\s*\s*\s*\s*`, 1, + time.Time{}, }, { "twice on fixed day", []string{strings.ToLower(fixedDay)[:3] + " *-*-* *:04..05"}, - `\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00\+00:00\s*\s*1\s*\s*<` + fixedDay + `>\s*\s*\s*`, + `\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00` + timezone + `\s*\s*1\s*\s*<` + fixedDay + `>\s*\s*\s*`, 48, + time.Time{}, }, // monthly { "once monthly", []string{"*-01-* 03:04"}, - `\s*\d{4}-01-\d{2}T03:04:00\+00:00\s*\s*\s*\s*\s*\s*` + generateEveryDayString() + `\s*\s*`, + `\s*\d{4}-01-\d{2}T03:04:00` + timezone + `\s*\s*\s*\s*\s*\s*` + generateEveryDayString() + `\s*\s*`, 1, + time.Time{}, }, { "every hour in january", []string{"*-01-* *:04"}, - `\s*\d{4}-01-\d{2}T\d{2}:04:00\+00:00\s*\s*\s*\s*\s*\s*` + generateEveryDayString() + `\s*\s*`, + `\s*\d{4}-01-\d{2}T\d{2}:04:00` + timezone + `\s*\s*\s*\s*\s*\s*` + generateEveryDayString() + `\s*\s*`, 24, + time.Time{}, }, // monthly with weekdays { "mondays in January", []string{"mon *-01-* 03:04"}, - `\s*\d{4}-01-\d{2}T03:04:00\+00:00\s*\s*\s*\s*\s*\s*1\s*2\s*3\s*4\s*Last\s*\s*\s*\s*\s*\s*`, + `\s*\d{4}-01-\d{2}T03:04:00` + timezone + `\s*\s*\s*\s*\s*\s*1\s*2\s*3\s*4\s*Last\s*\s*\s*\s*\s*\s*`, 1, + time.Time{}, }, { "every hour on Mondays in january", []string{"mon *-01-* *:04"}, - `\s*\d{4}-01-\d{2}T\d{2}:04:00\+00:00\s*\s*\s*\s*\s*\s*1\s*2\s*3\s*4\s*Last\s*\s*\s*\s*\s*\s*`, + `\s*\d{4}-01-\d{2}T\d{2}:04:00` + timezone + `\s*\s*\s*\s*\s*\s*1\s*2\s*3\s*4\s*Last\s*\s*\s*\s*\s*\s*`, 24, + time.Time{}, }, // // some days every month { "one day per month", []string{"*-*-0" + dayOfTheMonth + " 03:04"}, - `\s*\d{4}-\d{2}-0` + dayOfTheMonth + `T03:04:00\+00:00\s*\s*\s*` + generateEveryMonthString() + `\s*\s*` + dayOfTheMonth + `\s*\s*\s*`, + `\s*\d{4}-\d{2}-0` + dayOfTheMonth + `T03:04:00` + timezone + `\s*\s*\s*` + generateEveryMonthString() + `\s*\s*` + dayOfTheMonth + `\s*\s*\s*`, 1, + time.Time{}, }, { "every hour on the 1st of each month", []string{"*-*-0" + dayOfTheMonth + " *:04"}, - `\s*\d{4}-\d{2}-0` + dayOfTheMonth + `T\d{2}:04:00\+00:00\s*\s*\s*` + generateEveryMonthString() + `\s*\s*` + dayOfTheMonth + `\s*\s*\s*`, + `\s*\d{4}-\d{2}-0` + dayOfTheMonth + `T\d{2}:04:00` + timezone + `\s*\s*\s*` + generateEveryMonthString() + `\s*\s*` + dayOfTheMonth + `\s*\s*\s*`, 24, // 1 per hour + time.Time{}, }, // // more than once per month { "twice in one day per month", []string{"*-*-0" + dayOfTheMonth + " 03..04:04"}, - `\s*\d{4}-\d{2}-0` + dayOfTheMonth + `T\d{2}:04:00\+00:00\s*\s*\s*` + generateEveryMonthString() + `\s*\s*` + dayOfTheMonth + `\s*\s*\s*`, + `\s*\d{4}-\d{2}-0` + dayOfTheMonth + `T\d{2}:04:00` + timezone + `\s*\s*\s*` + generateEveryMonthString() + `\s*\s*` + dayOfTheMonth + `\s*\s*\s*`, 2, + time.Time{}, }, } @@ -196,7 +234,8 @@ func TestTriggerCreationFromXML(t *testing.T) { schedules[index] = event } buffer := &bytes.Buffer{} - task := createTaskDefinition(scheduleConfig, schedules) + task := createTaskDefinition(scheduleConfig, schedules, fixture.from) + taskInLocal(&task) err = createTaskFile(task, buffer) require.NoError(t, err) @@ -226,3 +265,19 @@ func generateEveryMonthString() string { } return everyMonth } + +func taskInLocal(task *Task) { + for i := range task.Triggers.TimeTrigger { + if task.Triggers.TimeTrigger[i].StartBoundary != nil { + *task.Triggers.TimeTrigger[i].StartBoundary = task.Triggers.TimeTrigger[i].StartBoundary.Local() + } + } + for i := range task.Triggers.CalendarTrigger { + if task.Triggers.CalendarTrigger[i].StartBoundary != nil { + *task.Triggers.CalendarTrigger[i].StartBoundary = task.Triggers.CalendarTrigger[i].StartBoundary.Local() + } + if task.Triggers.CalendarTrigger[i].EndBoundary != nil { + *task.Triggers.CalendarTrigger[i].EndBoundary = task.Triggers.CalendarTrigger[i].EndBoundary.Local() + } + } +} From 729153bef5fd81389b57c5063a11e236517f8feb Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 27 Mar 2026 22:24:11 +0000 Subject: [PATCH 2/4] fix: resolve flaky tests by ensuring consistent task creation timestamps --- schtasks/task.go | 4 ++-- schtasks/taskscheduler_test.go | 2 +- schtasks/trigger_test.go | 30 +++++++++++++++--------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/schtasks/task.go b/schtasks/task.go index 26d60df2..6ca00c14 100644 --- a/schtasks/task.go +++ b/schtasks/task.go @@ -43,7 +43,7 @@ func NewTask(options ...TaskOption) Task { Version: taskSchemaVersion, Xmlns: taskSchema, RegistrationInfo: RegistrationInfo{ - Date: time.Now().Format(dateFormat), + Date: time.Now().Format(dateFormat), // this will be overridden by setFromNow if needed Author: constants.ApplicationName, }, Principals: Principals{ @@ -70,7 +70,7 @@ func NewTask(options ...TaskOption) Task { Actions: Actions{ Context: author, }, - fromNow: time.Now(), + fromNow: time.Now(), // default will be overridden by setFromNow if needed } for _, option := range options { diff --git a/schtasks/taskscheduler_test.go b/schtasks/taskscheduler_test.go index f1a061c2..bbf0f6df 100644 --- a/schtasks/taskscheduler_test.go +++ b/schtasks/taskscheduler_test.go @@ -347,7 +347,7 @@ func TestStartWhenAvailableOption(t *testing.T) { defer file.Close() taskPath := getTaskPath(config.ProfileName, config.CommandName) - sourceTask := createTaskDefinition(config, schedules) + sourceTask := createTaskDefinition(config, schedules, time.Now()) sourceTask.RegistrationInfo.URI = taskPath // Verify StartWhenAvailable is set in source task diff --git a/schtasks/trigger_test.go b/schtasks/trigger_test.go index 7ff269c9..0f52080e 100644 --- a/schtasks/trigger_test.go +++ b/schtasks/trigger_test.go @@ -54,7 +54,7 @@ func TestTriggerCreationFromXML(t *testing.T) { []string{"*-*-* 03:04"}, `\s*\d{4}-\d{2}-\d{2}T03:04:00` + timezone + `\s*\s*1\s*\s*`, 1, - time.Time{}, + time.Date(2020, 1, 2, 3, 4, 0, 0, time.UTC), }, { "every hour", @@ -70,34 +70,34 @@ func TestTriggerCreationFromXML(t *testing.T) { 1, time.Time{}, }, + { + "every minute at 12 (before 12)", + []string{"*-*-* 12:*"}, + `\s*\d{4}-\d{2}-\d{2}T12:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, + 1, + time.Date(2025, 2, 27, 11, 20, 0, 0, time.UTC), + }, // { - // "every minute at 12 (before 12)", + // "every minute at 12", // []string{"*-*-* 12:*"}, - // `\s*\d{4}-\d{2}-\d{2}T11:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, + // `\s*\d{4}-\d{2}-\d{2}T12:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, // 1, - // time.Date(2025, 7, 27, 11, 20, 0, 0, time.UTC), + // time.Date(2025, 2, 27, 12, 20, 0, 0, time.UTC), // }, // { - // "every minute at 12", + // "every minute at 12 (after 12)", // []string{"*-*-* 12:*"}, - // `\s*\d{4}-\d{2}-\d{2}T12:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, + // `\s*\d{4}-\d{2}-\d{2}T13:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, // 1, - // time.Date(2025, 7, 27, 12, 20, 0, 0, time.UTC), + // time.Date(2025, 7, 27, 13, 20, 0, 0, time.UTC), // }, - { - "every minute at 12 (after 12)", - []string{"*-*-* 12:*"}, - `\s*\d{4}-\d{2}-\d{2}T13:\d{2}:00` + timezone + `\s*\s*PT1M\s*PT59M\s*\s*\s*1\s*\s*`, - 1, - time.Date(2025, 7, 27, 13, 20, 0, 0, time.UTC), - }, // daily - more than one { "three times a day", []string{"*-*-* 03..05:04"}, `\s*\d{4}-\d{2}-\d{2}T03:04:00` + timezone + `\s*\s*PT1H\s*PT2H\s*\s*\s*1\s*\s*`, 1, - time.Time{}, + time.Date(2026, 1, 2, 2, 0, 0, 0, time.UTC), }, { "twice every hour", From 7a50717dacfb4e81ffbabe09bbb8faf3fcfa2ae6 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 27 Mar 2026 22:58:22 +0000 Subject: [PATCH 3/4] test: improve logging for calendar and time triggers in task creation tests --- schtasks/taskscheduler_test.go | 18 +++++------------- schtasks/trigger_test.go | 9 ++++++++- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/schtasks/taskscheduler_test.go b/schtasks/taskscheduler_test.go index bbf0f6df..154808c8 100644 --- a/schtasks/taskscheduler_test.go +++ b/schtasks/taskscheduler_test.go @@ -153,12 +153,6 @@ func TestTaskSchedulerIntegration(t *testing.T) { []string{"*-*-* 12:*"}, time.Date(2025, 7, 27, 11, 20, 0, 0, time.UTC), }, - // this creates 60 triggers - // { - // "every minute at 12", - // []string{"*-*-* 12:*"}, - // time.Date(2025, 7, 27, 12, 20, 0, 0, time.UTC), - // }, { "every minute at 12 (after 12)", []string{"*-*-* 12:*"}, @@ -289,8 +283,7 @@ func TestTaskSchedulerIntegration(t *testing.T) { t.Logf("task contains %d time triggers and %d calendar triggers", len(sourceTask.Triggers.TimeTrigger), len(sourceTask.Triggers.CalendarTrigger)) - result, err := createTask(taskPath, file.Name(), "", "") - t.Log(result) + _, err = createTask(taskPath, file.Name(), "", "") require.NoError(t, err) taskXML, err := exportTaskDefinition(taskPath) @@ -302,17 +295,16 @@ func TestTaskSchedulerIntegration(t *testing.T) { // no need for character conversion return input, nil } - readTask := &Task{} + readTask := Task{} err = decoder.Decode(&readTask) require.NoError(t, err) sourceTask.fromNow = time.Time{} // ignore fromNow in the source task taskInUTC(&sourceTask) - taskInUTC(readTask) - assert.Equal(t, sourceTask, *readTask) + taskInUTC(&readTask) + assert.Equal(t, sourceTask, readTask) - result, err = deleteTask(taskPath) - t.Log(result) + _, err = deleteTask(taskPath) require.NoError(t, err) }) } diff --git a/schtasks/trigger_test.go b/schtasks/trigger_test.go index 0f52080e..68c498b0 100644 --- a/schtasks/trigger_test.go +++ b/schtasks/trigger_test.go @@ -235,7 +235,14 @@ func TestTriggerCreationFromXML(t *testing.T) { } buffer := &bytes.Buffer{} task := createTaskDefinition(scheduleConfig, schedules, fixture.from) - taskInLocal(&task) + + if len(task.Triggers.CalendarTrigger) > 0 { + t.Logf("calendar triggers %+v", task.Triggers.CalendarTrigger) + } + if len(task.Triggers.TimeTrigger) > 0 { + t.Logf("time triggers %+v", task.Triggers.TimeTrigger) + } + err = createTaskFile(task, buffer) require.NoError(t, err) From ce789aadc26ce76f030da752ffcefe39b099f83e Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 27 Mar 2026 23:09:37 +0000 Subject: [PATCH 4/4] fix: streamline task deletion and improve error handling --- schtasks/schtasks.go | 10 +++++----- schtasks/task.go | 14 +++++++------- schtasks/taskscheduler.go | 5 ++--- schtasks/taskscheduler_test.go | 4 ++-- schtasks/trigger_test.go | 16 ---------------- 5 files changed, 16 insertions(+), 33 deletions(-) diff --git a/schtasks/schtasks.go b/schtasks/schtasks.go index 311100b5..aa9dcf26 100644 --- a/schtasks/schtasks.go +++ b/schtasks/schtasks.go @@ -109,20 +109,20 @@ func listRegisteredTasks() ([]byte, error) { return stdout.Bytes(), err } -func deleteTask(taskName string) (string, error) { +func deleteTask(taskName string) error { taskName, err := sanitizeTaskName(taskName) if err != nil { - return "", err + return err } stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} cmd := exec.CommandContext(context.TODO(), binaryPath, "/delete", "/f", "/tn", taskName) - cmd.Stdout = stdout + cmd.Stdout = stdout // we're not actually interested in the output, but we need to capture it to avoid it being printed to the console cmd.Stderr = stderr err = cmd.Run() if err != nil { - return "", schTasksError(stderr.String()) + return schTasksError(stderr.String()) } - return stdout.String(), nil + return nil } // readTaskInfo returns the raw output from querying the task name (via schtasks.exe) diff --git a/schtasks/task.go b/schtasks/task.go index 6ca00c14..691ad6ce 100644 --- a/schtasks/task.go +++ b/schtasks/task.go @@ -177,9 +177,9 @@ func (t *Task) addDailyTrigger(schedule *calendar.Event) { return } // install them all - for _, recurrence := range recurrences { + for i := range recurrences { t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: &recurrence, + StartBoundary: &recurrences[i], ScheduleByDay: &ScheduleByDay{ DaysInterval: 1, }, @@ -231,9 +231,9 @@ func (t *Task) addWeeklyTrigger(schedule *calendar.Event) { return } // install them all - for _, recurrence := range recurrences { + for i := range recurrences { t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: &recurrence, + StartBoundary: &recurrences[i], ScheduleByWeek: &ScheduleByWeek{ WeeksInterval: 1, DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()), @@ -256,14 +256,14 @@ func (t *Task) addMonthlyTrigger(schedule *calendar.Event) { return } // install them all - for _, recurrence := range recurrences { + for i := range recurrences { if schedule.WeekDay.HasValue() && schedule.Day.HasValue() { clog.Warningf("task scheduler does not support a day of the month and a day of the week in the same trigger: %s", schedule.String()) return } if schedule.WeekDay.HasValue() { t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: &recurrence, + StartBoundary: &recurrences[i], ScheduleByMonthDayOfWeek: &ScheduleByMonthDayOfWeek{ DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()), Weeks: AllWeeks, @@ -273,7 +273,7 @@ func (t *Task) addMonthlyTrigger(schedule *calendar.Event) { continue } t.addCalendarTrigger(CalendarTrigger{ - StartBoundary: &recurrence, + StartBoundary: &recurrences[i], ScheduleByMonth: &ScheduleByMonth{ DaysOfMonth: convertDaysOfMonth(schedule.Day.GetRangeValues()), Months: convertMonths(schedule.Month.GetRangeValues()), diff --git a/schtasks/taskscheduler.go b/schtasks/taskscheduler.go index 99a8867f..06e1871c 100644 --- a/schtasks/taskscheduler.go +++ b/schtasks/taskscheduler.go @@ -49,7 +49,7 @@ func Create(config *Config, schedules []*calendar.Event, permission Permission) taskPath := getTaskPath(config.ProfileName, config.CommandName) if slices.Contains(list, taskPath) { clog.Debugf("task %q already exists: deleting before creating", taskPath) - _, err = deleteTask(taskPath) + err = deleteTask(taskPath) if err != nil { return fmt.Errorf("cannot delete existing task to replace it: %w", err) } @@ -112,8 +112,7 @@ func Create(config *Config, schedules []*calendar.Event, permission Permission) // Delete a task func Delete(title, subtitle string) error { taskName := getTaskPath(title, subtitle) - _, err := deleteTask(taskName) - return err + return deleteTask(taskName) } // Status returns the status of a task diff --git a/schtasks/taskscheduler_test.go b/schtasks/taskscheduler_test.go index 154808c8..565ec6dc 100644 --- a/schtasks/taskscheduler_test.go +++ b/schtasks/taskscheduler_test.go @@ -304,7 +304,7 @@ func TestTaskSchedulerIntegration(t *testing.T) { taskInUTC(&readTask) assert.Equal(t, sourceTask, readTask) - _, err = deleteTask(taskPath) + err = deleteTask(taskPath) require.NoError(t, err) }) } @@ -353,7 +353,7 @@ func TestStartWhenAvailableOption(t *testing.T) { t.Log(result) require.NoError(t, err) defer func() { - _, _ = deleteTask(taskPath) + _ = deleteTask(taskPath) }() // Export and verify the task was created with StartWhenAvailable diff --git a/schtasks/trigger_test.go b/schtasks/trigger_test.go index 68c498b0..41c9a7c9 100644 --- a/schtasks/trigger_test.go +++ b/schtasks/trigger_test.go @@ -272,19 +272,3 @@ func generateEveryMonthString() string { } return everyMonth } - -func taskInLocal(task *Task) { - for i := range task.Triggers.TimeTrigger { - if task.Triggers.TimeTrigger[i].StartBoundary != nil { - *task.Triggers.TimeTrigger[i].StartBoundary = task.Triggers.TimeTrigger[i].StartBoundary.Local() - } - } - for i := range task.Triggers.CalendarTrigger { - if task.Triggers.CalendarTrigger[i].StartBoundary != nil { - *task.Triggers.CalendarTrigger[i].StartBoundary = task.Triggers.CalendarTrigger[i].StartBoundary.Local() - } - if task.Triggers.CalendarTrigger[i].EndBoundary != nil { - *task.Triggers.CalendarTrigger[i].EndBoundary = task.Triggers.CalendarTrigger[i].EndBoundary.Local() - } - } -}