ruby_raider 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/e2e_tests.yml +3 -0
  3. data/README.md +52 -53
  4. data/RELEASE.md +14 -1
  5. data/lib/commands/schedule_commands.rb +84 -0
  6. data/lib/generators/templates/common/git_ignore.tt +1 -0
  7. data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
  8. data/lib/ruby_raider/runtime/rspec.rb +1 -0
  9. data/lib/ruby_raider/runtime/show_browser.rb +53 -0
  10. data/lib/ruby_raider/runtime.rb +2 -0
  11. data/lib/ruby_raider.rb +22 -7
  12. data/lib/utilities/schedules/adapter.rb +22 -0
  13. data/lib/utilities/schedules/base.rb +27 -0
  14. data/lib/utilities/schedules/kind.rb +74 -0
  15. data/lib/utilities/schedules/legacy.rb +196 -0
  16. data/lib/utilities/schedules/legacy_stub.rb +62 -0
  17. data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
  18. data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
  19. data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
  20. data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
  21. data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
  22. data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
  23. data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
  24. data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
  25. data/lib/utilities/schedules/os_enumerator.rb +30 -0
  26. data/lib/utilities/schedules/project.rb +70 -0
  27. data/lib/utilities/schedules/rule.rb +162 -0
  28. data/lib/utilities/schedules/run_lock.rb +160 -0
  29. data/lib/utilities/schedules/run_log.rb +122 -0
  30. data/lib/utilities/schedules/run_report.rb +139 -0
  31. data/lib/utilities/schedules/run_wrapper.rb +216 -0
  32. data/lib/utilities/schedules/schedule.rb +89 -0
  33. data/lib/utilities/schedules/schedule_store.rb +53 -0
  34. data/lib/utilities/schedules/scheduler.rb +178 -0
  35. data/lib/utilities/schedules/scheduler_steps.rb +69 -0
  36. data/lib/utilities/schedules/selection.rb +133 -0
  37. data/lib/utilities/schedules/spawn.rb +68 -0
  38. data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
  39. data/lib/version +1 -1
  40. data/ruby_raider.gemspec +1 -0
  41. data/sig/commands/schedule_commands.rbs +12 -0
  42. data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
  43. data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
  44. data/sig/ruby_raider.rbs +4 -0
  45. data/sig/utilities/schedules/adapter.rbs +5 -0
  46. data/sig/utilities/schedules/base.rbs +15 -0
  47. data/sig/utilities/schedules/kind.rbs +32 -0
  48. data/sig/utilities/schedules/legacy.rbs +45 -0
  49. data/sig/utilities/schedules/legacy_stub.rbs +15 -0
  50. data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
  51. data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
  52. data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
  53. data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
  54. data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
  55. data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
  56. data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
  57. data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
  58. data/sig/utilities/schedules/os_enumerator.rbs +12 -0
  59. data/sig/utilities/schedules/project.rbs +22 -0
  60. data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
  61. data/sig/utilities/schedules/run_lock.rbs +29 -0
  62. data/sig/utilities/schedules/run_log.rbs +33 -0
  63. data/sig/utilities/schedules/run_report.rbs +34 -0
  64. data/sig/utilities/schedules/run_wrapper.rbs +45 -0
  65. data/sig/utilities/schedules/schedule.rbs +27 -0
  66. data/sig/utilities/schedules/schedule_store.rbs +21 -0
  67. data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
  68. data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
  69. data/sig/utilities/schedules/selection.rbs +36 -0
  70. data/sig/utilities/schedules/spawn.rbs +10 -0
  71. data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
  72. data/spec/commands/schedule_commands_spec.rb +284 -0
  73. data/spec/integration/content/common_content_spec.rb +8 -2
  74. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  75. data/spec/integration/end_to_end_spec.rb +0 -63
  76. data/spec/runtime/retry_reset_spec.rb +112 -0
  77. data/spec/runtime/show_browser_project_spec.rb +70 -0
  78. data/spec/runtime/show_browser_spec.rb +101 -0
  79. data/spec/utilities/schedules/kind_spec.rb +81 -0
  80. data/spec/utilities/schedules/legacy_spec.rb +433 -0
  81. data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
  82. data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
  83. data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
  84. data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
  85. data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
  86. data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
  87. data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
  88. data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
  89. data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
  90. data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
  91. data/spec/utilities/schedules/os_entry_spec.rb +54 -0
  92. data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
  93. data/spec/utilities/schedules/project_spec.rb +125 -0
  94. data/spec/utilities/schedules/run_lock_spec.rb +143 -0
  95. data/spec/utilities/schedules/run_log_spec.rb +83 -0
  96. data/spec/utilities/schedules/run_report_spec.rb +150 -0
  97. data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
  98. data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
  99. data/spec/utilities/schedules/schedule_spec.rb +120 -0
  100. data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
  101. data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
  102. data/spec/utilities/schedules/scheduler_spec.rb +445 -0
  103. data/spec/utilities/schedules/selection_spec.rb +176 -0
  104. data/spec/utilities/schedules/spawn_spec.rb +104 -0
  105. metadata +106 -53
  106. data/lib/commands/flow_commands.rb +0 -104
  107. data/lib/generators/templates/flows/flow_helper.tt +0 -42
  108. data/lib/generators/templates/flows/run.tt +0 -332
  109. data/lib/utilities/flows/project.rb +0 -170
  110. data/lib/utilities/flows/run_log.rb +0 -61
  111. data/lib/utilities/flows/runner.rb +0 -206
  112. data/lib/utilities/flows/schedule.rb +0 -221
  113. data/lib/utilities/flows/scheduler.rb +0 -187
  114. data/sig/commands/flow_commands.rbs +0 -13
  115. data/sig/utilities/flows/project.rbs +0 -46
  116. data/sig/utilities/flows/run_log.rbs +0 -22
  117. data/sig/utilities/flows/runner.rbs +0 -35
  118. data/spec/commands/flow_commands_spec.rb +0 -128
  119. data/spec/generators/flow_helper_spec.rb +0 -40
  120. data/spec/integration/flows/flow_project.rb +0 -62
  121. data/spec/integration/flows/run_command_spec.rb +0 -91
  122. data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
  123. data/spec/utilities/flows/project_spec.rb +0 -178
  124. data/spec/utilities/flows/run_log_spec.rb +0 -47
  125. data/spec/utilities/flows/runner_spec.rb +0 -313
  126. data/spec/utilities/flows/schedule_spec.rb +0 -233
  127. data/spec/utilities/flows/schedule_store_spec.rb +0 -137
  128. data/spec/utilities/flows/scheduler_spec.rb +0 -329
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'digest'
5
+ require_relative '../../../lib/utilities/schedules/schedule'
6
+
7
+ RSpec.describe RubyRaider::Schedules::Schedule do # rubocop:disable RSpec/FilePath
8
+ let(:dir) { File.realpath(Dir.mktmpdir('schedules-schedule')) }
9
+ let(:project) { RubyRaider::Schedules::Project.new(dir) }
10
+ let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
11
+ let(:mac) { 'darwin24' }
12
+
13
+ before do
14
+ FileUtils.mkdir_p(File.join(dir, 'spec'))
15
+ File.write(File.join(dir, 'spec/a_spec.rb'), '')
16
+ end
17
+
18
+ after { FileUtils.rm_rf(dir) }
19
+
20
+ def build(**options)
21
+ described_class.build(project: project, name: 'nightly', every: 'day', at: '08:00', now: now, host_os: mac, **options)
22
+ end
23
+
24
+ def error_for(**options)
25
+ build(**options)
26
+ nil
27
+ rescue RubyRaider::Schedules::Error => e
28
+ e.message
29
+ end
30
+
31
+ def files(*names) = RubyRaider::Schedules::Selection.parse(files: names, tag: nil, root: dir, host_os: mac)
32
+
33
+ it 'keeps every field of the entry, with all tests by default' do
34
+ schedule = build(every: 'week', on: 'tue', retries: 2, notify: true, show_browser: true)
35
+ hash = schedule.to_h
36
+
37
+ expect(hash.keys).to eq(%w[name every at on retries notify show_browser selection os_id ruby bundle gem_env created_at])
38
+ expect(hash.slice('name', 'every', 'at', 'on', 'retries', 'notify', 'show_browser', 'selection', 'created_at'))
39
+ .to eq({ 'name' => 'nightly', 'every' => 'week', 'at' => '08:00', 'on' => 'tue', 'retries' => 2, 'notify' => true,
40
+ 'show_browser' => true, 'selection' => { 'kind' => 'all' }, 'created_at' => '2026-11-09T10:00:00Z' })
41
+ end
42
+
43
+ it 'names the os_id after the project and the name' do
44
+ expect(build.os_id).to eq("dev.rubyraider.schedule.#{Digest::SHA256.hexdigest(dir)[0, 10]}.nightly")
45
+ end
46
+
47
+ it 'runs raider schedule run NAME and nothing else from the OS entry' do
48
+ schedule = build(retries: 3, notify: true, show_browser: true, selection: files('spec/a_spec.rb'))
49
+
50
+ expect(schedule.argv).to eq([schedule.ruby, schedule.bundle, 'exec', 'raider', 'schedule', 'run', 'nightly', '--project', dir, '--trigger', 'schedule'])
51
+ end
52
+
53
+ it 'logs the scheduler output under .raider/schedules and keeps the 4.1 env' do
54
+ schedule = build
55
+
56
+ expect([schedule.log_path, schedule.env.keys.first(2), schedule.working_directory])
57
+ .to eq([File.join(dir, '.raider/schedules/nightly/scheduler.log'), %w[PATH LANG], dir])
58
+ end
59
+
60
+ it 'refuses show_browser on Linux and nowhere else' do
61
+ expect([error_for(show_browser: true, host_os: 'x86_64-linux-gnu'), error_for(show_browser: false, host_os: 'x86_64-linux-gnu'),
62
+ error_for(show_browser: true, host_os: 'mingw32')])
63
+ .to eq(["Show the browser isn't available for scheduled runs on Linux", nil, nil])
64
+ end
65
+
66
+ it 'refuses a bad name, show_browser or selection' do
67
+ errors = [error_for(name: 'Nightly'), error_for(name: 'a' * 61), error_for(show_browser: 'yes'), error_for(selection: { 'kind' => 'all' })]
68
+
69
+ expect(errors).to eq(['Invalid schedule name "Nightly": use 1 to 60 of a-z, 0-9 and _', "Invalid schedule name \"#{'a' * 61}\": use 1 to 60 of a-z, 0-9 and _",
70
+ 'Invalid show_browser "yes": use true or false', 'Invalid selection: use a Selection'])
71
+ end
72
+
73
+ it 'shares the rule checks with flow schedules' do
74
+ expect([error_for(every: 'month'), error_for(at: '8:00'), error_for(retries: 4), error_for(notify: 'yes')])
75
+ .to eq(['Invalid every "month": use hour, day, weekday or week', 'Invalid at "8:00": use HH:MM (24h)',
76
+ 'Invalid retries 4: use 0 to 3', 'Invalid notify "yes": use true or false'])
77
+ end
78
+
79
+ describe '.from_h' do
80
+ it 'reads back what to_h wrote' do
81
+ schedule = build(selection: files('spec/a_spec.rb'), show_browser: true)
82
+
83
+ expect(described_class.from_h(schedule.to_h, dir, host_os: mac).to_h).to eq(schedule.to_h)
84
+ end
85
+
86
+ it 'loads an entry whose spec file is gone' do
87
+ hash = build(selection: files('spec/a_spec.rb')).to_h
88
+ File.delete(File.join(dir, 'spec/a_spec.rb'))
89
+
90
+ expect(described_class.from_h(hash, dir, host_os: mac).selection.missing_file(dir)).to eq('spec/a_spec.rb')
91
+ end
92
+
93
+ it 'treats an entry without show_browser as off' do
94
+ expect(described_class.from_h(build.to_h.except('show_browser'), dir, host_os: mac).show_browser).to be(false)
95
+ end
96
+
97
+ it 'refuses an entry from another project, a flow entry, a broken selection or a non-mapping' do
98
+ other = File.realpath(Dir.mktmpdir('schedules-other'))
99
+ flow = build.to_h.merge('os_id' => build.os_id.sub('.schedule.', '.flow.'))
100
+ entries = [[build.to_h, other], [flow, dir], [build.to_h.merge('selection' => { 'kind' => 'tag', 'tag' => 'a;b' }), dir],
101
+ [build.to_h.except('selection'), dir], ['nightly', dir]]
102
+ errors = entries.map do |hash, root|
103
+ described_class.from_h(hash, root, host_os: mac)
104
+ nil
105
+ rescue RubyRaider::Schedules::Error => e
106
+ e.message
107
+ end
108
+
109
+ expect(errors).to all(be_a(String))
110
+ ensure
111
+ FileUtils.rm_rf(other)
112
+ end
113
+ end
114
+
115
+ it 'is frozen' do
116
+ schedule = build(selection: files('spec/a_spec.rb'))
117
+
118
+ expect([schedule, schedule.selection, schedule.name]).to all(be_frozen)
119
+ end
120
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../../../lib/utilities/schedules/schedule_store'
5
+
6
+ RSpec.describe RubyRaider::Schedules::ScheduleStore do # rubocop:disable RSpec/FilePath
7
+ let(:dir) { File.realpath(Dir.mktmpdir('schedules-store')) }
8
+ let(:project) { RubyRaider::Schedules::Project.new(dir) }
9
+ let(:store) { described_class.new(project) }
10
+ let(:file) { File.join(dir, '.raider/schedules/schedules.yml') }
11
+ let(:gitignore) { File.join(dir, '.gitignore') }
12
+
13
+ before do
14
+ FileUtils.mkdir_p(File.join(dir, 'spec'))
15
+ File.write(File.join(dir, 'spec/a_spec.rb'), '')
16
+ end
17
+
18
+ after { FileUtils.rm_rf(dir) }
19
+
20
+ def schedule(name, **options)
21
+ RubyRaider::Schedules::Schedule.build(project: project, name: name, every: 'day', at: '08:00', host_os: 'darwin24', **options)
22
+ end
23
+
24
+ def files(*names) = RubyRaider::Schedules::Selection.parse(files: names, tag: nil, root: dir, host_os: 'darwin24')
25
+
26
+ it 'saves to .raider/schedules/schedules.yml, 0600 in a 0700 folder, sorted, and reads it back' do
27
+ store.save(schedule('zeta', selection: files('spec/a_spec.rb'), show_browser: true))
28
+ store.save(schedule('alpha'))
29
+
30
+ expect([store.all.map(&:name), store.find('zeta').selection.files, store.find('zeta').show_browser]).to eq([%w[alpha zeta], %w[spec/a_spec.rb], true])
31
+ expect([File.stat(file).mode & 0o777, File.stat(File.dirname(file)).mode & 0o777]).to eq([0o600, 0o700])
32
+ expect(YAML.safe_load_file(file)['schedules'].last['selection']).to eq({ 'kind' => 'files', 'files' => %w[spec/a_spec.rb] })
33
+ end
34
+
35
+ it 'still lists a schedule whose spec file is gone' do
36
+ store.save(schedule('nightly', selection: files('spec/a_spec.rb')))
37
+ File.delete(File.join(dir, 'spec/a_spec.rb'))
38
+
39
+ expect(store.find('nightly').selection.missing_file(dir)).to eq('spec/a_spec.rb')
40
+ end
41
+
42
+ it 'skips an entry that fails re-validation and keeps it when it writes' do
43
+ FileUtils.mkdir_p(File.dirname(file))
44
+ hostile = schedule('bad').to_h.merge('selection' => { 'kind' => 'files', 'files' => ['../x_spec.rb'] })
45
+ File.write(file, { 'schedules' => [hostile, 'junk'] }.to_yaml)
46
+ store.save(schedule('nightly'))
47
+
48
+ expect([store.all.map(&:name), YAML.safe_load_file(file)['schedules'].size]).to eq([%w[nightly], 3])
49
+ end
50
+
51
+ it 'refuses to overwrite a file that is not a schedules file' do
52
+ FileUtils.mkdir_p(File.dirname(file))
53
+ File.write(file, "schedules: [\n")
54
+
55
+ expect { store.save(schedule('nightly')) }
56
+ .to raise_error(RubyRaider::Schedules::Error, '.raider/schedules/schedules.yml is not a schedules file: fix or remove it, then try again')
57
+ expect(File.read(file)).to eq("schedules: [\n")
58
+ end
59
+
60
+ it 'refuses a linked schedules folder' do
61
+ FileUtils.mkdir_p(File.join(dir, 'elsewhere'))
62
+ FileUtils.mkdir_p(File.join(dir, '.raider'))
63
+ File.symlink(File.join(dir, 'elsewhere'), File.join(dir, '.raider/schedules'))
64
+
65
+ expect { store.save(schedule('nightly')) }.to raise_error(RubyRaider::Schedules::Error, '.raider/schedules must be a folder, not a link')
66
+ end
67
+
68
+ it 'does not load Ruby objects' do
69
+ FileUtils.mkdir_p(File.dirname(file))
70
+ File.write(file, "--- !ruby/object:OpenStruct\ntable: {}\n")
71
+
72
+ expect(store.all).to eq([])
73
+ end
74
+
75
+ it 'returns the name and test os_id of an entry it removed but could not read' do
76
+ FileUtils.mkdir_p(File.dirname(file))
77
+ File.write(file, { 'schedules' => [{ 'name' => 'nightly' }] }.to_yaml)
78
+ removed = store.delete('nightly')
79
+
80
+ expect([removed.name, removed.os_id]).to eq(['nightly', RubyRaider::Schedules::Schedule.os_id_for(dir, 'nightly')])
81
+ end
82
+
83
+ describe '#ignore_in_git' do
84
+ it 'creates .gitignore with the line when there is none' do
85
+ expect([store.ignore_in_git, File.read(gitignore)]).to eq([true, ".raider/schedules/\n"])
86
+ end
87
+
88
+ it 'appends the line, keeping every byte and adding a newline first when the file lacks one' do
89
+ File.binwrite(gitignore, "node_modules/\r\n\xFFraw")
90
+
91
+ expect([store.ignore_in_git, File.binread(gitignore)]).to eq([true, "node_modules/\r\n\xFFraw\n.raider/schedules/\n".b])
92
+ end
93
+
94
+ it 'leaves the file alone when the exact line is there, and only adds it once' do
95
+ File.write(gitignore, "a/\n.raider/schedules/\r\nb/")
96
+ before = File.binread(gitignore)
97
+
98
+ expect([store.ignore_in_git, File.binread(gitignore)]).to eq([false, before])
99
+ end
100
+
101
+ it 'adds the line when only a comment, a longer line or a prefix mentions it' do
102
+ File.write(gitignore, "# .raider/schedules/\n.raider/schedules/x\n.raider/\n")
103
+ first = store.ignore_in_git
104
+
105
+ expect([first, store.ignore_in_git, File.read(gitignore).lines.last]).to eq([true, false, ".raider/schedules/\n"])
106
+ end
107
+
108
+ it 'refuses a .gitignore that is not a file' do
109
+ FileUtils.mkdir_p(gitignore)
110
+
111
+ expect { store.ignore_in_git }.to raise_error(RubyRaider::Schedules::Error, '.gitignore must be a file')
112
+ end
113
+
114
+ it 'refuses a linked .gitignore and never writes through it' do
115
+ target = File.join(File.realpath(Dir.mktmpdir('outside')), 'ignored')
116
+ File.write(target, "keep\n")
117
+ File.symlink(target, gitignore)
118
+
119
+ expect { store.ignore_in_git }.to raise_error(RubyRaider::Schedules::Error, '.gitignore is a link: add .raider/schedules/ to it yourself')
120
+ expect([File.read(target), File.symlink?(gitignore)]).to eq(["keep\n", true])
121
+ ensure
122
+ FileUtils.rm_rf(File.dirname(target))
123
+ end
124
+
125
+ it 'refuses a dangling .gitignore link without making its target' do
126
+ target = File.join(dir, 'made_by_the_link')
127
+ File.symlink(target, gitignore)
128
+
129
+ expect { store.ignore_in_git }.to raise_error(RubyRaider::Schedules::Error, /is a link/)
130
+ expect(File.exist?(target)).to be(false)
131
+ end
132
+
133
+ it 'leaves a read-only .gitignore that has the line alone, and says so when it lacks it' do
134
+ File.write(gitignore, ".raider/schedules/\n")
135
+ File.chmod(0o444, gitignore)
136
+ expect(store.ignore_in_git).to be(false)
137
+
138
+ File.chmod(0o644, gitignore)
139
+ File.write(gitignore, "a/\n")
140
+ File.chmod(0o444, gitignore)
141
+ expect { store.ignore_in_git }.to raise_error(Errno::EACCES)
142
+ expect(File.read(gitignore)).to eq("a/\n")
143
+ end
144
+
145
+ it 'refuses a .gitignore that is a FIFO without blocking' do
146
+ File.mkfifo(gitignore)
147
+
148
+ expect { store.ignore_in_git }.to raise_error(RubyRaider::Schedules::Error, '.gitignore must be a file')
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'tmpdir'
5
+ require_relative '../../../lib/utilities/schedules/scheduler'
6
+
7
+ # The scheduler with the real launchd adapter: plists in a temp home, launchctl and plutil played by a fake
8
+ # runner. Nothing real is run and the real ~/Library/LaunchAgents is never read.
9
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
10
+ RSpec.describe RubyRaider::Schedules::Scheduler do # rubocop:disable RSpec/FilePath
11
+ # Records every command and answers success; launchctl print succeeds only for a loaded service
12
+ let(:runner_class) do
13
+ Class.new do
14
+ attr_reader :calls, :loaded
15
+
16
+ def initialize
17
+ @calls = []
18
+ @loaded = []
19
+ end
20
+
21
+ def call(*argv, **)
22
+ @calls << argv
23
+ verb = argv[1]
24
+ target = argv.last
25
+ status = 0
26
+ case verb
27
+ when 'bootstrap' then @loaded << File.basename(target, '.plist')
28
+ when 'bootout' then status = @loaded.delete(target.split('/').last) ? 0 : 3
29
+ when 'print' then status = @loaded.include?(target.split('/').last) ? 0 : 113
30
+ end
31
+ RubyRaider::Schedules::Os::CommandRunner::Result.new(stdout: '', stderr: '', exitstatus: status)
32
+ end
33
+ end
34
+ end
35
+
36
+ let(:tmp) { File.realpath(Dir.mktmpdir('schedules-launchd-wiring')) }
37
+ let(:home) { File.join(tmp, 'home') }
38
+ let(:root) do
39
+ File.join(tmp, 'project').tap do |dir|
40
+ FileUtils.mkdir_p(dir)
41
+ # add checks the project's bundle has `raider schedule`
42
+ File.write(File.join(dir, 'Gemfile'), "gem 'ruby_raider'\n")
43
+ File.write(File.join(dir, 'Gemfile.lock'), "GEM\n specs:\n ruby_raider (#{RubyRaider::Schedules::Project::OWN_VERSION})\n")
44
+ end
45
+ end
46
+ let(:project) { RubyRaider::Schedules::Project.new(root) }
47
+ let(:runner) { runner_class.new }
48
+ let(:launchd) { RubyRaider::Schedules::Os::Launchd.new(kind: RubyRaider::Schedules::Kind::TESTS, runner: runner, home: home, uid: 501) }
49
+ let(:out) { StringIO.new }
50
+ let(:scheduler) do
51
+ described_class.new(project, adapter: launchd, out: out, err: StringIO.new, now: -> { Time.local(2026, 11, 8, 12) }, host_os: 'darwin25')
52
+ end
53
+ let(:os_id) { RubyRaider::Schedules::Schedule.os_id_for(project.root, 'nightly') }
54
+ let(:plist) { File.join(home, 'Library', 'LaunchAgents', "#{os_id}.plist") }
55
+
56
+ after { FileUtils.rm_rf(tmp) }
57
+
58
+ def states
59
+ out.truncate(0)
60
+ out.rewind
61
+ scheduler.list(json: true)
62
+ JSON.parse(out.string)['schedules'].map { |row| [row['name'], row['os_state']] }
63
+ end
64
+
65
+ it 'adds, lists an entry schedules.yml lost, and removes it, touching only the temp home' do
66
+ scheduler.add('nightly', every: 'day', at: '08:00')
67
+ expect([File.file?(plist), states]).to eq([true, [%w[nightly ok]]])
68
+
69
+ File.write(File.join(project.root, '.raider/schedules/schedules.yml'), "schedules: []\n")
70
+ expect(states).to eq([['nightly', 'missing from schedules.yml']])
71
+
72
+ scheduler.remove('nightly')
73
+ expect([File.exist?(plist), states, out.string]).to eq([false, [], %({"notice":null,"schedules":[]}\n)])
74
+ expect(runner.calls.flatten.select { |arg| arg.start_with?('/') && !arg.start_with?('/bin/', '/usr/bin/') }).to all(start_with(tmp))
75
+ end
76
+ end
77
+ # rubocop:enable RSpec/MultipleMemoizedHelpers