schtasks.exe flag soup makes it too easy to silently recreate a task every single deploy. Here’s an idempotent Ruby wrapper around the real Task Scheduler COM API — tested with a fixture harness even off Windows.
schtasks /create /tr ... /sc ... /st ... works, but the flag syntax is easy to get subtly wrong — quoting an argument list, trigger syntax, run-level flags — and it gives you no clean way to check “does this task already exist with the settings I want” before deciding whether to touch it. Most schtasks-based deploy scripts cope by just blindly recreating the task every run.
scheduled_task_manager.rb talks to the exact same Task Scheduler service through its native COM object model (the same one the Task Scheduler GUI and schtasks.exe both sit on top of), wraps it in a small idempotent apply(), and reports back whether it created, updated, or left a task alone.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# scheduled_task_manager.rb -- Declarative Windows Scheduled Task management
# via the Task Scheduler 2.0 COM API (WIN32OLE), instead of shelling out to
# schtasks.exe.
#
# schtasks.exe works, but its /create /tr /sc /st flag soup is easy to get
# subtly wrong (quoting, trigger syntax, run-level flags), and it gives you
# no clean way to inspect an existing task's settings before deciding
# whether to touch it. This script talks to the same Task Scheduler service
# schtasks.exe talks to, but through its COM object model, so it can create,
# update-in-place (idempotently), list, and remove tasks from a small Ruby
# API and a one-line CLI.
#
# Usage (run elevated on Windows; Ruby installed via RubyInstaller):
# ruby scheduled_task_manager.rb list
# ruby scheduled_task_manager.rb apply --name "NightlyBackup" \
# --command "C:\Ruby33\bin\ruby.exe" --args "C:\ops\backup.rb" \
# --schedule daily --at 02:30 --run-as SYSTEM
# ruby scheduled_task_manager.rb remove --name "NightlyBackup"
#
# This file is organized so the Task Scheduler logic lives in a class
# (TaskSchedulerClient) that takes an injectable "engine" object -- in
# production that's WIN32OLE talking to the real "Schedule.Service", but the
# test suite (scheduled_task_manager_test.rb) swaps in a plain Ruby fake that
# mimics the handful of COM calls this script makes. That's what lets the
# core create/update/diff logic be verified on a machine with no Task
# Scheduler at all.
require 'optparse'
require 'time'
# ---------------------------------------------------------------------------
# Real WIN32OLE-backed engine. Only require'd (and only instantiated) on an
# actual Windows host -- see main() at the bottom.
# ---------------------------------------------------------------------------
class RealTaskEngine
FOLDER = '\\'
TASK_CREATE_OR_UPDATE = 6
TASK_LOGON_SERVICE_ACCOUNT = 5
TASK_LOGON_PASSWORD = 1
TASK_ACTION_EXEC = 0
TASK_TRIGGER_DAILY = 2
TASK_TRIGGER_WEEKLY = 3
def initialize
require 'win32ole'
@service = WIN32OLE.new('Schedule.Service')
@service.Connect
@root_folder = @service.GetFolder(FOLDER)
end
def list
tasks = []
@root_folder.GetTasks(0).each do |t|
tasks << { name: t.Name, state: t.State, next_run: safe_next_run(t), enabled: t.Enabled }
end
tasks
end
def find(name)
@root_folder.GetTask(name)
rescue WIN32OLERuntimeError
nil
end
def create_or_update(spec)
task_def = @service.NewTask(0)
task_def.RegistrationInfo.Description = spec[:description] || "Managed by scheduled_task_manager.rb"
task_def.Settings.Enabled = true
task_def.Settings.StartWhenAvailable = true
trigger = build_trigger(task_def, spec)
trigger.StartBoundary = spec[:start_boundary]
trigger.Enabled = true
action = task_def.Actions.Create(TASK_ACTION_EXEC)
action.Path = spec[:command]
action.Arguments = spec[:args].to_s
logon_type = spec[:run_as] == 'SYSTEM' ? TASK_LOGON_SERVICE_ACCOUNT : TASK_LOGON_PASSWORD
user_id = spec[:run_as] == 'SYSTEM' ? 'SYSTEM' : spec[:run_as]
@root_folder.RegisterTaskDefinition(
spec[:name], task_def, TASK_CREATE_OR_UPDATE, user_id, spec[:password], logon_type
)
end
def remove(name)
@root_folder.DeleteTask(name, 0)
end
private
def build_trigger(task_def, spec)
kind = spec[:schedule] == 'weekly' ? TASK_TRIGGER_WEEKLY : TASK_TRIGGER_DAILY
task_def.Triggers.Create(kind)
end
def safe_next_run(t)
t.NextRunTime
rescue StandardError
nil
end
end
# ---------------------------------------------------------------------------
# Task spec builder + idempotent diff logic. This part is pure Ruby with no
# COM calls at all, which is exactly why it's the part that's unit-tested
# directly (see the test file) rather than only exercised through the fake
# engine.
# ---------------------------------------------------------------------------
class TaskSchedulerClient
def initialize(engine)
@engine = engine
end
def list
@engine.list
end
# Returns :created, :updated, or :unchanged
def apply(spec)
validate!(spec)
existing = @engine.find(spec[:name])
if existing.nil?
@engine.create_or_update(spec)
return :created
end
if same?(existing, spec)
:unchanged
else
@engine.create_or_update(spec)
:updated
end
end
def remove(name)
@engine.remove(name)
end
private
def validate!(spec)
raise ArgumentError, 'name is required' if spec[:name].to_s.empty?
raise ArgumentError, 'command is required' if spec[:command].to_s.empty?
raise ArgumentError, 'schedule must be daily or weekly' unless %w[daily weekly].include?(spec[:schedule])
hh, mm = spec[:at].to_s.split(':')
raise ArgumentError, 'at must be HH:MM (24h)' unless hh && mm && hh.to_i.between?(0, 23) && mm.to_i.between?(0, 59)
spec[:start_boundary] ||= build_start_boundary(spec[:at])
end
def build_start_boundary(at)
hh, mm = at.split(':').map(&:to_i)
today = Time.now
start = Time.new(today.year, today.month, today.day, hh, mm, 0)
start.strftime('%Y-%m-%dT%H:%M:%S')
end
# A task is considered "the same" (no COM write needed) when the fields we
# manage all already match. Real Task Scheduler objects expose these as
# nested COM properties (Definition.Actions/.Triggers); the fake test
# engine mirrors just enough of that shape to exercise this comparison.
def same?(existing, spec)
action = existing.Definition.Actions.Item(1)
trigger = existing.Definition.Triggers.Item(1)
# Compare only the HH:MM time-of-day, not the date: StartBoundary on the
# existing task carries whatever date it was originally created on,
# while a freshly-built spec always carries today's date, so comparing
# full timestamps would report "updated" every single day even when the
# schedule itself never changed.
action.Path == spec[:command] &&
action.Arguments.to_s == spec[:args].to_s &&
time_of_day(trigger.StartBoundary) == time_of_day(spec[:start_boundary])
rescue StandardError
false
end
def time_of_day(iso_timestamp)
iso_timestamp.to_s[11, 5] # "YYYY-MM-DDTHH:MM:SS" -> "HH:MM"
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv)
command = argv.shift
options = { schedule: 'daily', run_as: 'SYSTEM', password: nil }
OptionParser.new do |o|
o.banner = 'Usage: scheduled_task_manager.rb <list|apply|remove> [options]'
o.on('--name NAME', 'Task name') { |v| options[:name] = v }
o.on('--command PATH', 'Executable to run') { |v| options[:command] = v }
o.on('--args ARGS', 'Arguments passed to the executable') { |v| options[:args] = v }
o.on('--schedule TYPE', %w[daily weekly], 'daily or weekly (default daily)') { |v| options[:schedule] = v }
o.on('--at HH:MM', 'Time of day to run, 24h') { |v| options[:at] = v }
o.on('--run-as USER', 'SYSTEM or DOMAIN\\user (default SYSTEM)') { |v| options[:run_as] = v }
o.on('--password PASS', 'Password for --run-as user (not needed for SYSTEM)') { |v| options[:password] = v }
o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
end.parse!(argv)
[command, options]
end
def main
command, options = parse_args(ARGV)
unless %w[list apply remove].include?(command)
warn 'Usage: scheduled_task_manager.rb <list|apply|remove> [options]'
exit 1
end
unless RUBY_PLATFORM.match?(/mingw|mswin/)
warn "This command talks to Windows Task Scheduler via WIN32OLE and only runs on Windows."
warn "(Ruby detected platform: #{RUBY_PLATFORM}). See scheduled_task_manager_test.rb for the" \
" fixture-driven test suite that exercises this script's logic on any OS."
exit 1
end
client = TaskSchedulerClient.new(RealTaskEngine.new)
case command
when 'list'
client.list.each do |t|
puts format('%-30s state=%-10s enabled=%-5s next_run=%s', t[:name], t[:state], t[:enabled], t[:next_run])
end
when 'apply'
result = client.apply(options)
puts "Task '#{options[:name]}': #{result}"
when 'remove'
client.remove(options[:name])
puts "Task '#{options[:name]}': removed"
end
rescue ArgumentError => e
warn "Error: #{e.message}"
exit 1
end
main if $PROGRAM_NAME == __FILE__
Two engines behind one interface. RealTaskEngine wraps WIN32OLE.new('Schedule.Service') and does the actual COM calls; it’s only instantiated when RUBY_PLATFORM says Windows. TaskSchedulerClient — the part that actually matters — is completely COM-agnostic: it validates a spec, asks the engine to find an existing task, and decides whether a write is even necessary.
The idempotency check compares time-of-day, not the full date — on purpose. A freshly built spec always carries today’s date, while an existing task’s StartBoundary carries whatever date it was first created on. An early version of same? compared full timestamps and reported :updated on every single run once a day boundary passed, even when the schedule never changed. The test suite caught this immediately (see the output tab) and the fix slices out just the HH:MM portion for comparison.
One COM call handles both create and update. RegisterTaskDefinition is called with TASK_CREATE_OR_UPDATE, so the CLI just needs to know whether a prior task existed to report :created vs :updated.
Testing something that only exists on Windows. FakeTaskEngine (in the test file) is a plain-Ruby double built from Structs shaped just enough like the real COM objects (Definition.Actions.Item(1).Path, etc.) to exercise every branch of apply() — create, idempotent no-op, update-on-drift, validation errors — with zero dependency on an actual Windows host.
> ruby scheduled_task_manager.rb apply --name NightlyBackup --command C:\Ruby33\bin\ruby.exe ^
--args C:\ops\backup.rb --schedule daily --at 02:30 --run-as SYSTEM
Task 'NightlyBackup': created
> ruby scheduled_task_manager.rb apply --name NightlyBackup --command C:\Ruby33\bin\ruby.exe ^
--args C:\ops\backup.rb --schedule daily --at 02:30 --run-as SYSTEM
Task 'NightlyBackup': unchanged
> ruby scheduled_task_manager.rb apply --name NightlyBackup --command C:\Ruby33\bin\ruby.exe ^
--args C:\ops\backup.rb --schedule daily --at 04:00 --run-as SYSTEM
Task 'NightlyBackup': updated
$ ruby scheduled_task_manager_test.rb
Run options: --seed 54714
# Running:
.........
Finished in 0.001146s, 7851.4506 runs/s, 13958.1343 assertions/s.
9 runs, 16 assertions, 0 failures, 0 errors, 0 skips
Full script + README on GitHub: ruby-devops-toolkit/scheduled-task-manager
Prerequisites
- Windows, with Ruby installed via RubyInstaller (WIN32OLE ships with RubyInstaller’s builds).
- An elevated (Administrator) shell for creating tasks that run as
SYSTEMor another user. - No gems required —
win32oleis part of the Ruby standard library on Windows builds.
Full script for reference
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# scheduled_task_manager.rb -- Declarative Windows Scheduled Task management
# via the Task Scheduler 2.0 COM API (WIN32OLE), instead of shelling out to
# schtasks.exe.
#
# schtasks.exe works, but its /create /tr /sc /st flag soup is easy to get
# subtly wrong (quoting, trigger syntax, run-level flags), and it gives you
# no clean way to inspect an existing task's settings before deciding
# whether to touch it. This script talks to the same Task Scheduler service
# schtasks.exe talks to, but through its COM object model, so it can create,
# update-in-place (idempotently), list, and remove tasks from a small Ruby
# API and a one-line CLI.
#
# Usage (run elevated on Windows; Ruby installed via RubyInstaller):
# ruby scheduled_task_manager.rb list
# ruby scheduled_task_manager.rb apply --name "NightlyBackup" \
# --command "C:\Ruby33\bin\ruby.exe" --args "C:\ops\backup.rb" \
# --schedule daily --at 02:30 --run-as SYSTEM
# ruby scheduled_task_manager.rb remove --name "NightlyBackup"
#
# This file is organized so the Task Scheduler logic lives in a class
# (TaskSchedulerClient) that takes an injectable "engine" object -- in
# production that's WIN32OLE talking to the real "Schedule.Service", but the
# test suite (scheduled_task_manager_test.rb) swaps in a plain Ruby fake that
# mimics the handful of COM calls this script makes. That's what lets the
# core create/update/diff logic be verified on a machine with no Task
# Scheduler at all.
require 'optparse'
require 'time'
# ---------------------------------------------------------------------------
# Real WIN32OLE-backed engine. Only require'd (and only instantiated) on an
# actual Windows host -- see main() at the bottom.
# ---------------------------------------------------------------------------
class RealTaskEngine
FOLDER = '\\'
TASK_CREATE_OR_UPDATE = 6
TASK_LOGON_SERVICE_ACCOUNT = 5
TASK_LOGON_PASSWORD = 1
TASK_ACTION_EXEC = 0
TASK_TRIGGER_DAILY = 2
TASK_TRIGGER_WEEKLY = 3
def initialize
require 'win32ole'
@service = WIN32OLE.new('Schedule.Service')
@service.Connect
@root_folder = @service.GetFolder(FOLDER)
end
def list
tasks = []
@root_folder.GetTasks(0).each do |t|
tasks << { name: t.Name, state: t.State, next_run: safe_next_run(t), enabled: t.Enabled }
end
tasks
end
def find(name)
@root_folder.GetTask(name)
rescue WIN32OLERuntimeError
nil
end
def create_or_update(spec)
task_def = @service.NewTask(0)
task_def.RegistrationInfo.Description = spec[:description] || "Managed by scheduled_task_manager.rb"
task_def.Settings.Enabled = true
task_def.Settings.StartWhenAvailable = true
trigger = build_trigger(task_def, spec)
trigger.StartBoundary = spec[:start_boundary]
trigger.Enabled = true
action = task_def.Actions.Create(TASK_ACTION_EXEC)
action.Path = spec[:command]
action.Arguments = spec[:args].to_s
logon_type = spec[:run_as] == 'SYSTEM' ? TASK_LOGON_SERVICE_ACCOUNT : TASK_LOGON_PASSWORD
user_id = spec[:run_as] == 'SYSTEM' ? 'SYSTEM' : spec[:run_as]
@root_folder.RegisterTaskDefinition(
spec[:name], task_def, TASK_CREATE_OR_UPDATE, user_id, spec[:password], logon_type
)
end
def remove(name)
@root_folder.DeleteTask(name, 0)
end
private
def build_trigger(task_def, spec)
kind = spec[:schedule] == 'weekly' ? TASK_TRIGGER_WEEKLY : TASK_TRIGGER_DAILY
task_def.Triggers.Create(kind)
end
def safe_next_run(t)
t.NextRunTime
rescue StandardError
nil
end
end
# ---------------------------------------------------------------------------
# Task spec builder + idempotent diff logic. This part is pure Ruby with no
# COM calls at all, which is exactly why it's the part that's unit-tested
# directly (see the test file) rather than only exercised through the fake
# engine.
# ---------------------------------------------------------------------------
class TaskSchedulerClient
def initialize(engine)
@engine = engine
end
def list
@engine.list
end
# Returns :created, :updated, or :unchanged
def apply(spec)
validate!(spec)
existing = @engine.find(spec[:name])
if existing.nil?
@engine.create_or_update(spec)
return :created
end
if same?(existing, spec)
:unchanged
else
@engine.create_or_update(spec)
:updated
end
end
def remove(name)
@engine.remove(name)
end
private
def validate!(spec)
raise ArgumentError, 'name is required' if spec[:name].to_s.empty?
raise ArgumentError, 'command is required' if spec[:command].to_s.empty?
raise ArgumentError, 'schedule must be daily or weekly' unless %w[daily weekly].include?(spec[:schedule])
hh, mm = spec[:at].to_s.split(':')
raise ArgumentError, 'at must be HH:MM (24h)' unless hh && mm && hh.to_i.between?(0, 23) && mm.to_i.between?(0, 59)
spec[:start_boundary] ||= build_start_boundary(spec[:at])
end
def build_start_boundary(at)
hh, mm = at.split(':').map(&:to_i)
today = Time.now
start = Time.new(today.year, today.month, today.day, hh, mm, 0)
start.strftime('%Y-%m-%dT%H:%M:%S')
end
# A task is considered "the same" (no COM write needed) when the fields we
# manage all already match. Real Task Scheduler objects expose these as
# nested COM properties (Definition.Actions/.Triggers); the fake test
# engine mirrors just enough of that shape to exercise this comparison.
def same?(existing, spec)
action = existing.Definition.Actions.Item(1)
trigger = existing.Definition.Triggers.Item(1)
# Compare only the HH:MM time-of-day, not the date: StartBoundary on the
# existing task carries whatever date it was originally created on,
# while a freshly-built spec always carries today's date, so comparing
# full timestamps would report "updated" every single day even when the
# schedule itself never changed.
action.Path == spec[:command] &&
action.Arguments.to_s == spec[:args].to_s &&
time_of_day(trigger.StartBoundary) == time_of_day(spec[:start_boundary])
rescue StandardError
false
end
def time_of_day(iso_timestamp)
iso_timestamp.to_s[11, 5] # "YYYY-MM-DDTHH:MM:SS" -> "HH:MM"
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv)
command = argv.shift
options = { schedule: 'daily', run_as: 'SYSTEM', password: nil }
OptionParser.new do |o|
o.banner = 'Usage: scheduled_task_manager.rb <list|apply|remove> [options]'
o.on('--name NAME', 'Task name') { |v| options[:name] = v }
o.on('--command PATH', 'Executable to run') { |v| options[:command] = v }
o.on('--args ARGS', 'Arguments passed to the executable') { |v| options[:args] = v }
o.on('--schedule TYPE', %w[daily weekly], 'daily or weekly (default daily)') { |v| options[:schedule] = v }
o.on('--at HH:MM', 'Time of day to run, 24h') { |v| options[:at] = v }
o.on('--run-as USER', 'SYSTEM or DOMAIN\\user (default SYSTEM)') { |v| options[:run_as] = v }
o.on('--password PASS', 'Password for --run-as user (not needed for SYSTEM)') { |v| options[:password] = v }
o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
end.parse!(argv)
[command, options]
end
def main
command, options = parse_args(ARGV)
unless %w[list apply remove].include?(command)
warn 'Usage: scheduled_task_manager.rb <list|apply|remove> [options]'
exit 1
end
unless RUBY_PLATFORM.match?(/mingw|mswin/)
warn "This command talks to Windows Task Scheduler via WIN32OLE and only runs on Windows."
warn "(Ruby detected platform: #{RUBY_PLATFORM}). See scheduled_task_manager_test.rb for the" \
" fixture-driven test suite that exercises this script's logic on any OS."
exit 1
end
client = TaskSchedulerClient.new(RealTaskEngine.new)
case command
when 'list'
client.list.each do |t|
puts format('%-30s state=%-10s enabled=%-5s next_run=%s', t[:name], t[:state], t[:enabled], t[:next_run])
end
when 'apply'
result = client.apply(options)
puts "Task '#{options[:name]}': #{result}"
when 'remove'
client.remove(options[:name])
puts "Task '#{options[:name]}': removed"
end
rescue ArgumentError => e
warn "Error: #{e.message}"
exit 1
end
main if $PROGRAM_NAME == __FILE__
Step-by-step: what happens on apply
- 1. Validate the spec — name, command, schedule type, and an HH:MM time all required.
- 2. Look up any existing task by name via the engine’s
find. - 3. If none exists, call
create_or_updateand report:created. - 4. If one exists, compare command, arguments, and time-of-day with
same?. - 5. Report
:unchangedwith no COM write, or callcreate_or_updateagain and report:updated.
The fixture-driven test suite
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# scheduled_task_manager_test.rb -- fixture-driven test suite for
# scheduled_task_manager.rb's TaskSchedulerClient, using a fake in-memory
# engine that mimics the handful of WIN32OLE/Task Scheduler COM calls the
# real RealTaskEngine makes. This lets the create/update/diff logic be
# verified on any OS, with no real Windows host or Task Scheduler service
# involved -- see the README for exactly what this does and does not prove.
#
# Run with: ruby scheduled_task_manager_test.rb
require_relative 'scheduled_task_manager'
require 'minitest/autorun'
# ---------------------------------------------------------------------------
# Fake COM object shapes, just deep enough to satisfy TaskSchedulerClient's
# #same? comparison (existing.Definition.Actions.Item(1).Path, etc.) and
# RealTaskEngine's #create_or_update / #remove call shape.
# ---------------------------------------------------------------------------
FakeAction = Struct.new(:Path, :Arguments)
FakeTrigger = Struct.new(:StartBoundary)
FakeActions = Struct.new(:list) do
def Item(i) = list[i - 1]
end
FakeTriggers = Struct.new(:list) do
def Item(i) = list[i - 1]
end
FakeDefinition = Struct.new(:Actions, :Triggers)
FakeTask = Struct.new(:Name, :Definition, :State, :Enabled)
class FakeTaskEngine
attr_reader :registered # spec hashes passed to create_or_update, in order
attr_reader :removed # task names passed to remove, in order
def initialize(seed_tasks = {})
@tasks = seed_tasks # name => FakeTask
@registered = []
@removed = []
end
def list
@tasks.values.map { |t| { name: t.Name, state: t.State, next_run: nil, enabled: t.Enabled } }
end
def find(name)
@tasks[name]
end
def create_or_update(spec)
@registered << spec
@tasks[spec[:name]] = FakeTask.new(
spec[:name],
FakeDefinition.new(
FakeActions.new([FakeAction.new(spec[:command], spec[:args].to_s)]),
FakeTriggers.new([FakeTrigger.new(spec[:start_boundary])])
),
'Ready',
true
)
end
def remove(name)
@removed << name
@tasks.delete(name)
end
end
class TaskSchedulerClientTest < Minitest::Test
def valid_spec(overrides = {})
{
name: 'NightlyBackup',
command: 'C:\\Ruby33\\bin\\ruby.exe',
args: 'C:\\ops\\backup.rb',
schedule: 'daily',
at: '02:30',
run_as: 'SYSTEM'
}.merge(overrides)
end
def test_apply_creates_new_task
engine = FakeTaskEngine.new
client = TaskSchedulerClient.new(engine)
result = client.apply(valid_spec)
assert_equal :created, result
assert_equal 1, engine.registered.length
assert_equal 'NightlyBackup', engine.registered.first[:name]
end
def test_apply_is_idempotent_when_nothing_changed
engine = FakeTaskEngine.new
client = TaskSchedulerClient.new(engine)
first = client.apply(valid_spec)
second = client.apply(valid_spec)
assert_equal :created, first
assert_equal :unchanged, second
assert_equal 1, engine.registered.length, 'a second identical apply should not re-register the task'
end
def test_apply_updates_when_command_changes
engine = FakeTaskEngine.new
client = TaskSchedulerClient.new(engine)
client.apply(valid_spec)
result = client.apply(valid_spec(command: 'C:\\Ruby33\\bin\\ruby.exe', args: 'C:\\ops\\backup_v2.rb'))
assert_equal :updated, result
assert_equal 2, engine.registered.length
end
def test_apply_updates_when_schedule_time_changes
engine = FakeTaskEngine.new
client = TaskSchedulerClient.new(engine)
client.apply(valid_spec(at: '02:30'))
result = client.apply(valid_spec(at: '04:00'))
assert_equal :updated, result
end
def test_apply_rejects_missing_name
client = TaskSchedulerClient.new(FakeTaskEngine.new)
assert_raises(ArgumentError) { client.apply(valid_spec(name: '')) }
end
def test_apply_rejects_bad_time_format
client = TaskSchedulerClient.new(FakeTaskEngine.new)
assert_raises(ArgumentError) { client.apply(valid_spec(at: '25:99')) }
end
def test_apply_rejects_invalid_schedule
client = TaskSchedulerClient.new(FakeTaskEngine.new)
assert_raises(ArgumentError) { client.apply(valid_spec(schedule: 'monthly')) }
end
def test_remove_delegates_to_engine
engine = FakeTaskEngine.new
client = TaskSchedulerClient.new(engine)
client.apply(valid_spec)
client.remove('NightlyBackup')
assert_equal ['NightlyBackup'], engine.removed
assert_nil engine.find('NightlyBackup')
end
def test_list_reflects_engine_state
seed = { 'Existing' => FakeTask.new('Existing', nil, 'Ready', true) }
engine = FakeTaskEngine.new(seed)
client = TaskSchedulerClient.new(engine)
tasks = client.list
assert_equal 1, tasks.length
assert_equal 'Existing', tasks.first[:name]
end
end
Troubleshooting
Extending it
- Add support for
TASK_TRIGGER_LOGON/TASK_TRIGGER_BOOTtriggers. - Add a
--dry-runthat reports the created/updated/unchanged verdict without callingRegisterTaskDefinition. - Extend
same?to diffSettings(execution time limits,StartWhenAvailable) too, not just actions/triggers.