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,445 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rbconfig'
5
+ require 'stringio'
6
+ require 'tmpdir'
7
+ require 'yaml'
8
+ require_relative '../../../lib/utilities/schedules/scheduler'
9
+
10
+ # The fake adapter, store and streams sit next to the project and scheduler
11
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
12
+ RSpec.describe RubyRaider::Schedules::Scheduler do # rubocop:disable RSpec/FilePath
13
+ # Stands in for an OS scheduler: records every call, keeps the installed os_ids; list_ids returns them plus
14
+ # whatever `extra` holds, unfiltered, so the scheduler's own checks are what keeps noise out
15
+ let(:fake_adapter) do
16
+ Class.new do
17
+ attr_reader :calls, :installed, :schedules, :extra
18
+ attr_accessor :fail_present, :fail_list, :fail_remove, :note
19
+
20
+ def initialize
21
+ @calls = []
22
+ @installed = []
23
+ @schedules = []
24
+ @extra = []
25
+ end
26
+
27
+ def label = 'fakeos'
28
+
29
+ def install(schedule)
30
+ @calls << [:install, schedule.os_id]
31
+ @schedules << schedule
32
+ @installed |= [schedule.os_id]
33
+ true
34
+ end
35
+
36
+ def remove(os_id)
37
+ @calls << [:remove, os_id]
38
+ raise RubyRaider::Schedules::Error, 'launchctl refused' if fail_remove
39
+
40
+ !@installed.delete(os_id).nil?
41
+ end
42
+
43
+ def present?(os_id)
44
+ raise RubyRaider::Schedules::Error, 'task list unreadable' if fail_present
45
+
46
+ @installed.include?(os_id)
47
+ end
48
+
49
+ def list_ids(_prefix)
50
+ raise RubyRaider::Schedules::Error, 'task list unreadable' if fail_list
51
+
52
+ @installed + @extra
53
+ end
54
+
55
+ def missed_runs_note = note
56
+ end
57
+ end
58
+
59
+ let(:dir) { File.realpath(Dir.mktmpdir('schedules-scheduler')) }
60
+ let(:project) { RubyRaider::Schedules::Project.new(dir) }
61
+ let(:adapter) { fake_adapter.new }
62
+ let(:store) { RubyRaider::Schedules::ScheduleStore.new(project) }
63
+ let(:out) { StringIO.new }
64
+ let(:err) { StringIO.new }
65
+ # Sun 8 Nov 2026 12:00 local
66
+ let(:now) { Time.local(2026, 11, 8, 12, 0) }
67
+ let(:host_os) { 'darwin25' }
68
+ let(:scheduler) { described_class.new(project, adapter: adapter, store: store, out: out, err: err, now: -> { now }, host_os: host_os) }
69
+ let(:os_id) { RubyRaider::Schedules::Schedule.os_id_for(dir, 'nightly') }
70
+ let(:yml) { File.join(dir, '.raider/schedules/schedules.yml') }
71
+
72
+ before do
73
+ %w[spec/a_spec.rb spec/b_spec.rb].each do |file|
74
+ FileUtils.mkdir_p(File.dirname(File.join(dir, file)))
75
+ File.write(File.join(dir, file), '')
76
+ end
77
+ # add checks the project's bundle has `raider schedule`
78
+ File.write(File.join(dir, 'Gemfile'), "gem 'ruby_raider'\n")
79
+ File.write(File.join(dir, 'Gemfile.lock'), "GEM\n specs:\n ruby_raider (#{RubyRaider::Schedules::Project::OWN_VERSION})\n")
80
+ end
81
+
82
+ after { FileUtils.rm_rf(dir) }
83
+
84
+ def add(name = 'nightly', every: 'day', at: '08:00', **options) = scheduler.add(name, every: every, at: at, **options)
85
+
86
+ def log_run(name, *lines)
87
+ file = File.join(dir, ".raider/schedules/#{name}/runs.jsonl")
88
+ FileUtils.mkdir_p(File.dirname(file))
89
+ File.write(file, lines.map { |line| "#{JSON.generate(line)}\n" }.join, mode: 'a')
90
+ end
91
+
92
+ def edit_yml
93
+ data = YAML.safe_load_file(yml)
94
+ yield data['schedules']
95
+ File.write(yml, YAML.dump(data))
96
+ end
97
+
98
+ def mode(relative) = File.stat(File.join(dir, relative)).mode & 0o777
99
+
100
+ # What list prints, without what add printed before it
101
+ def listed(json: true)
102
+ out.truncate(0)
103
+ out.rewind
104
+ scheduler.list(json: json)
105
+ json ? JSON.parse(out.string) : out.string
106
+ end
107
+
108
+ describe '.adapter' do
109
+ let(:runner) { instance_double(RubyRaider::Schedules::Os::CommandRunner) }
110
+
111
+ it 'picks launchd on macOS and Task Scheduler on Windows for test schedules, without calling them' do
112
+ picked = %w[darwin25 mingw32].map { |host| described_class.adapter(runner: runner, host_os: host) }
113
+
114
+ expect(picked.map(&:class)).to eq([RubyRaider::Schedules::Os::Launchd, RubyRaider::Schedules::Os::TaskScheduler])
115
+ expect(picked.map { |item| item.instance_variable_get(:@kind) }).to all(be(RubyRaider::Schedules::Kind::TESTS))
116
+ end
117
+
118
+ it 'refuses another OS, also when add picks it lazily, before any folder is made' do
119
+ expect { described_class.adapter(runner: runner, host_os: 'solaris2') }
120
+ .to raise_error(RubyRaider::Schedules::Error, 'Scheduling tests is not supported on solaris2')
121
+
122
+ lazy = described_class.new(project, out: out, err: err, now: -> { now }, host_os: 'solaris2')
123
+ expect { lazy.add('nightly', every: 'day', at: '08:00') }
124
+ .to raise_error(RubyRaider::Schedules::Error, 'Scheduling tests is not supported on solaris2')
125
+ expect(File.exist?(File.join(dir, '.raider'))).to be(false)
126
+ end
127
+ end
128
+
129
+ describe '#add' do
130
+ it 'installs the OS entry, then writes the yml, ignores the folder in git and says when it next runs' do
131
+ allow(store).to receive(:save).and_wrap_original do |original, schedule|
132
+ expect(adapter.installed).to include(schedule.os_id)
133
+ original.call(schedule)
134
+ end
135
+
136
+ add(every: 'weekday')
137
+
138
+ expect(out.string).to eq("Scheduled nightly: every weekday at 08:00, next run Mon 08:00\n")
139
+ expect([adapter.calls, store.find('nightly').selection.to_h]).to eq([[[:install, os_id]], { 'kind' => 'all' }])
140
+ expect([File.read(File.join(dir, '.gitignore')), err.string]).to eq([".raider/schedules/\n", ''])
141
+ end
142
+
143
+ it 'says Replaced when the name already had a schedule, and keeps one entry' do
144
+ add
145
+ add(every: 'week', at: '09:30', on: 'wed', tag: 'smoke')
146
+
147
+ expect(out.string.lines.last).to eq("Replaced nightly: every week on wed at 09:30, next run Wed 09:30\n")
148
+ expect(store.all.map { |item| [item.rule_text, item.selection.to_h] }).to eq([['every week on wed at 09:30', { 'kind' => 'tag', 'tag' => 'smoke' }]])
149
+ end
150
+
151
+ it 'stores spec files, a tag, retries, notify and show_browser as given' do
152
+ File.write(File.join(dir, 'helpers/driver_helper.rb').tap { |file| FileUtils.mkdir_p(File.dirname(file)) }, "require 'ruby_raider/runtime'\n")
153
+ add('files', files: %w[spec/b_spec.rb spec/a_spec.rb], retries: 2, notify: true, show_browser: true)
154
+ add('tagged', every: 'hour', at: ':15', tag: 'smoke')
155
+
156
+ expect(store.all.map { |item| [item.name, item.selection.to_h, item.retries, item.notify, item.show_browser, item.at] })
157
+ .to eq([['files', { 'kind' => 'files', 'files' => %w[spec/b_spec.rb spec/a_spec.rb] }, 2, true, true, '08:00'],
158
+ ['tagged', { 'kind' => 'tag', 'tag' => 'smoke' }, 0, false, false, '00:15']])
159
+ expect(out.string.lines.last).to eq("Scheduled tagged: every hour at :15, next run Sun 12:15\n")
160
+ end
161
+
162
+ it 'installs from a freshly built schedule, never from the ruby, bundle or gem env of the yml it replaces' do
163
+ add
164
+ edit_yml { |entries| entries.first.merge!('ruby' => '/evil/ruby', 'bundle' => '/evil/bundle', 'gem_env' => { 'GEM_HOME' => '/evil' }) }
165
+ add
166
+
167
+ installed = adapter.schedules.last
168
+ expect([installed.ruby, installed.bundle, installed.gem_env]).to eq([RbConfig.ruby, RubyRaider::Schedules::Schedule.bundle_path,
169
+ RubyRaider::Schedules::Schedule.captured_gem_env])
170
+ expect(store.find('nightly').ruby).to eq(RbConfig.ruby)
171
+ end
172
+
173
+ it 'makes .raider/schedules and .raider/schedules/<name> 0700 folders' do
174
+ add
175
+
176
+ expect([mode('.raider/schedules'), mode('.raider/schedules/nightly'), mode('.raider/schedules/schedules.yml')]).to eq([0o700, 0o700, 0o600])
177
+ end
178
+
179
+ it 'removes the OS entry again when the yml cannot be written' do
180
+ allow(store).to receive(:save).and_raise(RubyRaider::Schedules::Error, 'disk full')
181
+
182
+ expect { add }.to raise_error(RubyRaider::Schedules::Error, 'disk full: the fakeos entry was removed too, so nightly is not scheduled')
183
+ expect([adapter.calls, adapter.installed]).to eq([[[:install, os_id], [:remove, os_id]], []])
184
+ end
185
+
186
+ it 'says so when the rollback fails too' do
187
+ allow(store).to receive(:save).and_raise(Errno::ENOSPC)
188
+ adapter.fail_remove = true
189
+
190
+ expect { add }.to raise_error(RubyRaider::Schedules::Error, /No space left on device.*\(and removing the fakeos entry failed: launchctl refused\)/)
191
+ end
192
+
193
+ context 'when refused before the OS scheduler or any file is touched' do
194
+ def refused(message, **options)
195
+ expect { scheduler.add(options.delete(:name) || 'nightly', every: 'day', at: '08:00', **options) }
196
+ .to raise_error(RubyRaider::Schedules::Error, message)
197
+ expect([adapter.calls, File.exist?(File.join(dir, '.raider')), File.exist?(File.join(dir, '.gitignore'))]).to eq([[], false, false])
198
+ end
199
+
200
+ it 'refuses a bad name' do
201
+ ['Nightly', 'a' * 61, '../x', '', "a\nb"].each { |name| refused(/Invalid schedule name/, name: name) }
202
+ end
203
+
204
+ it 'refuses a missing, hostile or outside spec file, and a tag with files' do
205
+ refused('spec/c_spec.rb not found', files: %w[spec/c_spec.rb])
206
+ refused(/Invalid spec file/, files: ['../x_spec.rb'])
207
+ refused(/Invalid spec file/, files: ['-rf_spec.rb'])
208
+ refused('Use --tag or spec files, not both', files: %w[spec/a_spec.rb], tag: 'smoke')
209
+ refused(/Invalid tag/, tag: '$(id)')
210
+ end
211
+
212
+ it 'refuses a project whose bundle has no raider schedule, after the other checks' do
213
+ File.write(File.join(dir, 'Gemfile.lock'), "GEM\n specs:\n ruby_raider (4.0.1)\n")
214
+ refused(/\AScheduling needs ruby_raider \S+ or later in this project: bundle update ruby_raider\z/)
215
+ File.delete(File.join(dir, 'Gemfile.lock'))
216
+ refused(/: run bundle install first\z/)
217
+ refused(/Invalid every/, every: 'yearly')
218
+ end
219
+
220
+ it 'refuses a bad rule' do
221
+ refused(/Invalid every/, every: 'yearly')
222
+ end
223
+
224
+ it 'refuses show_browser on Linux, before checking the project' do
225
+ allow(project).to receive(:check_show_browser!)
226
+ linux = described_class.new(project, adapter: adapter, store: store, out: out, err: err, now: -> { now }, host_os: 'linux-gnu')
227
+
228
+ expect { linux.add('nightly', every: 'day', at: '08:00', show_browser: true) }
229
+ .to raise_error(RubyRaider::Schedules::Error, "Show the browser isn't available for scheduled runs on Linux")
230
+ expect([adapter.calls, File.exist?(File.join(dir, '.raider'))]).to eq([[], false])
231
+ expect(project).not_to have_received(:check_show_browser!)
232
+ end
233
+
234
+ it 'refuses show_browser for a project made before 4.0' do
235
+ refused('Show the browser needs a project made with Ruby Raider 4.0 or later', show_browser: true)
236
+ end
237
+
238
+ it 'refuses Windows-expanding file names on Windows' do
239
+ File.write(File.join(dir, 'spec/a%PATH%_spec.rb'), '')
240
+ windows = described_class.new(project, adapter: adapter, store: store, out: out, err: err, now: -> { now }, host_os: 'mingw32')
241
+
242
+ expect { windows.add('nightly', every: 'day', at: '08:00', files: ['spec/a%PATH%_spec.rb']) }.to raise_error(RubyRaider::Schedules::Error)
243
+ expect(adapter.calls).to eq([])
244
+ end
245
+ end
246
+
247
+ it 'refuses a linked schedule folder before the OS scheduler is touched' do
248
+ FileUtils.mkdir_p(File.join(dir, '.raider/schedules'))
249
+ File.symlink(FileUtils.mkdir_p(File.join(dir, 'elsewhere')).first, File.join(dir, '.raider/schedules/nightly'))
250
+
251
+ expect { add }.to raise_error(RubyRaider::Schedules::Error, '.raider/schedules/nightly must be a folder, not a link')
252
+ expect(adapter.calls).to eq([])
253
+ end
254
+
255
+ it 'refuses a schedules.yml it can not rewrite before the OS scheduler is touched' do
256
+ FileUtils.mkdir_p(File.dirname(yml))
257
+ File.write(yml, "schedules: nope\n")
258
+
259
+ expect { add }.to raise_error(RubyRaider::Schedules::Error, /not a schedules file/)
260
+ expect(adapter.calls).to eq([])
261
+ end
262
+
263
+ it 'still schedules, with a warning, when .gitignore can not take the line' do
264
+ target = File.join(dir, 'elsewhere_ignore')
265
+ File.write(target, "keep\n")
266
+ File.symlink(target, File.join(dir, '.gitignore'))
267
+
268
+ add
269
+
270
+ expect(out.string).to start_with('Scheduled nightly')
271
+ expect(err.string).to eq("Could not add .raider/schedules/ to .gitignore: .gitignore is a link: add .raider/schedules/ to it yourself\n")
272
+ expect([File.read(target), store.find('nightly').name]).to eq(%W[keep\n nightly])
273
+ end
274
+
275
+ it 'warns, and still schedules, when .gitignore can not be written' do
276
+ allow(store).to receive(:ignore_in_git).and_raise(Errno::EACCES, '.gitignore')
277
+
278
+ add
279
+
280
+ expect([out.string.lines.size, err.string]).to eq([1, "Could not add .raider/schedules/ to .gitignore: Permission denied - .gitignore\n"])
281
+ end
282
+ end
283
+
284
+ describe '#remove' do
285
+ it 'removes the OS entry first, then the yml entry' do
286
+ add
287
+ allow(store).to receive(:delete).and_wrap_original do |original, name|
288
+ expect(adapter.installed).to be_empty
289
+ original.call(name)
290
+ end
291
+
292
+ scheduler.remove('nightly')
293
+
294
+ expect([out.string.lines.last, store.find('nightly'), adapter.calls.last]).to eq(["Removed nightly\n", nil, [:remove, os_id]])
295
+ end
296
+
297
+ it 'says when the OS entry was already gone, and when only the OS entry was left' do
298
+ add
299
+ adapter.installed.clear
300
+ scheduler.remove('nightly')
301
+ adapter.installed << os_id
302
+ scheduler.remove('nightly')
303
+
304
+ expect(out.string.lines.last(2)).to eq(["Removed nightly (it was already gone from fakeos)\n", "Removed nightly\n"])
305
+ end
306
+
307
+ it 'refuses a name with no schedule, and a bad name without asking the OS' do
308
+ expect { scheduler.remove('nightly') }.to raise_error(RubyRaider::Schedules::Error, 'No schedule nightly')
309
+ expect { scheduler.remove('../x') }.to raise_error(RubyRaider::Schedules::Error, /Invalid schedule name/)
310
+ expect(adapter.calls).to eq([[:remove, os_id]])
311
+ end
312
+
313
+ it 'keeps the yml entry when the OS scheduler refuses' do
314
+ add
315
+ adapter.fail_remove = true
316
+
317
+ expect { scheduler.remove('nightly') }.to raise_error(RubyRaider::Schedules::Error, 'launchctl refused')
318
+ expect(store.find('nightly')).not_to be_nil
319
+ end
320
+
321
+ it 'says the OS entry is gone when the yml can not be rewritten' do
322
+ add
323
+ allow(store).to receive(:delete).and_raise(RubyRaider::Schedules::Error, 'disk full')
324
+
325
+ expect { scheduler.remove('nightly') }
326
+ .to raise_error(RubyRaider::Schedules::Error, 'disk full: the fakeos entry was already removed, but schedules.yml still lists nightly')
327
+ end
328
+
329
+ it 'warns when the yml entry it removed could not be read' do
330
+ add
331
+ edit_yml { |entries| entries.first['every'] = 'yearly' }
332
+
333
+ scheduler.remove('nightly')
334
+
335
+ expect(err.string).to include('could not be read', 'fakeos entry made from a different project path may remain')
336
+ expect(File.read(yml)).not_to include('nightly')
337
+ end
338
+ end
339
+
340
+ describe '#list' do
341
+ let(:zone) { Time.local(2026, 11, 9, 8, 0).strftime('%:z') }
342
+
343
+ it 'is the JSON object with notice null and one row per schedule, in the spec shape' do
344
+ add('all_tests', every: 'weekday')
345
+ add('files', every: 'hour', at: ':15', files: %w[spec/a_spec.rb], retries: 1, notify: true)
346
+ log_run('all_tests', { 'status' => 'failed', 'finished_at' => '2026-11-06T08:01:00Z', 'trigger' => 'schedule',
347
+ 'counts' => { 'total' => 3, 'passed' => 2, 'failed' => 1, 'skipped' => 0 }, 'run_id' => 'x' },
348
+ { 'status' => 'busy', 'finished_at' => '2026-11-07T08:00:00Z', 'trigger' => 'schedule' })
349
+
350
+ expect(listed).to eq(
351
+ 'notice' => nil,
352
+ 'schedules' => [
353
+ { 'name' => 'all_tests', 'every' => 'weekday', 'at' => '08:00', 'on' => nil, 'retries' => 0, 'notify' => false, 'show_browser' => false,
354
+ 'selection' => { 'kind' => 'all' }, 'next_run_at' => "2026-11-09T08:00:00#{zone}", 'os_state' => 'ok',
355
+ 'last' => { 'status' => 'failed', 'finished_at' => '2026-11-06T08:01:00Z', 'trigger' => 'schedule',
356
+ 'counts' => { 'total' => 3, 'passed' => 2, 'failed' => 1, 'skipped' => 0 } } },
357
+ { 'name' => 'files', 'every' => 'hour', 'at' => '00:15', 'on' => nil, 'retries' => 1, 'notify' => true, 'show_browser' => false,
358
+ 'selection' => { 'kind' => 'files', 'files' => %w[spec/a_spec.rb] }, 'next_run_at' => Time.local(2026, 11, 8, 12, 15).iso8601,
359
+ 'os_state' => 'ok', 'last' => nil }
360
+ ]
361
+ )
362
+ end
363
+
364
+ it 'gives each os_state, the file check before asking the OS' do
365
+ %w[ok gone ruby file unknown].each { |name| add(name, files: %w[spec/a_spec.rb spec/b_spec.rb]) }
366
+ adapter.installed.delete(RubyRaider::Schedules::Schedule.os_id_for(dir, 'gone'))
367
+ edit_yml do |entries|
368
+ entries.find { |entry| entry['name'] == 'ruby' }['ruby'] = File.join(dir, 'no_ruby')
369
+ entries.find { |entry| entry['name'] == 'file' }['selection'] = { 'kind' => 'files', 'files' => %w[spec/a_spec.rb spec/gone_spec.rb spec/x_spec.rb] }
370
+ end
371
+ allow(adapter).to receive(:present?).and_wrap_original do |original, id|
372
+ raise RubyRaider::Schedules::Error, "task list \xFFunreadable\e".dup.force_encoding(Encoding::UTF_8) if id.end_with?('.unknown')
373
+ raise 'asked the OS for a schedule whose file is gone' if id.end_with?('.file')
374
+
375
+ original.call(id)
376
+ end
377
+
378
+ expect(listed['schedules'].to_h { |row| [row['name'], row['os_state']] })
379
+ .to eq('file' => 'test file not found: spec/gone_spec.rb', 'gone' => 'missing from fakeos', 'ok' => 'ok',
380
+ 'ruby' => 'Ruby not found: re-schedule', 'unknown' => 'unknown (task list ?unreadable?)')
381
+ end
382
+
383
+ it "adds a row for each of this project's OS entries that schedules.yml lacks, and ignores every other id" do
384
+ add
385
+ prefix = RubyRaider::Schedules::Schedule.os_id_prefix_for(dir)
386
+ log_run('ghost', { 'status' => 'passed', 'finished_at' => 'f', 'trigger' => 'manual', 'counts' => {}, 'report_copy' => 'x' })
387
+ adapter.extra.push("#{prefix}ghost", "#{prefix}ghost", RubyRaider::Schedules::Schedule.os_id_for('/elsewhere', 'stranger'),
388
+ Class.new(RubyRaider::Schedules::Rule) { def self.kind = RubyRaider::Schedules::Kind::FLOW_LEGACY }.os_id_for(dir, 'legacy'), "#{prefix}../../etc", "#{prefix}a;id", "#{prefix}Caps",
389
+ "#{prefix}a\xFF".dup.force_encoding(Encoding::UTF_8), nil)
390
+
391
+ rows = listed['schedules']
392
+
393
+ expect(rows.map { |row| row['name'] }).to eq(%w[nightly ghost])
394
+ expect(rows.last).to eq('name' => 'ghost', 'every' => nil, 'at' => nil, 'on' => nil, 'retries' => nil, 'notify' => nil, 'show_browser' => nil,
395
+ 'selection' => nil, 'next_run_at' => nil, 'os_state' => 'missing from schedules.yml',
396
+ 'last' => { 'status' => 'passed', 'finished_at' => 'f', 'trigger' => 'manual', 'counts' => {} })
397
+ end
398
+
399
+ it 'lists only schedules.yml rows when the OS scheduler can not be enumerated' do
400
+ add
401
+ adapter.fail_list = true
402
+
403
+ expect(listed['schedules'].map { |row| row['name'] }).to eq(%w[nightly])
404
+ end
405
+
406
+ it 'still lists, with unknown states, where there is no OS scheduler to ask' do
407
+ add
408
+ lazy = described_class.new(project, out: out, err: err, now: -> { now }, host_os: 'solaris2')
409
+ out.truncate(0)
410
+ out.rewind
411
+
412
+ lazy.list
413
+
414
+ expect(out.string).to end_with("never run unknown (Scheduling tests is not supported on solaris2)\n")
415
+ expect(out.string.lines.size).to eq(1)
416
+ end
417
+
418
+ it 'is an empty list, or the add hint as text, with no schedules' do
419
+ expect(listed).to eq('notice' => nil, 'schedules' => [])
420
+ expect(listed(json: false)).to eq("No schedules yet: raider schedule add <name> --every day --at 08:00\n")
421
+ end
422
+
423
+ it 'passes a notice through in JSON, and leaves it out of the text (it is on stderr already)' do
424
+ scheduler.list(json: true, notice: 'Removed 1 old schedule')
425
+ scheduler.list(notice: 'Removed 1 old schedule')
426
+
427
+ expect(JSON.parse(out.string.lines.first)['notice']).to eq('Removed 1 old schedule')
428
+ expect([out.string.lines.drop(1), err.string]).to eq([["No schedules yet: raider schedule add <name> --every day --at 08:00\n"], ''])
429
+ end
430
+
431
+ it 'prints one printable line per row, then the missed runs note' do
432
+ add('nightly', tag: 'smoke', notify: true)
433
+ log_run('nightly', { 'status' => "passed\e[2J", 'finished_at' => '2026-11-07T08:01:00Z' })
434
+ adapter.extra << RubyRaider::Schedules::Schedule.os_id_for(dir, 'ghost')
435
+ adapter.note = '(cron: missed runs are skipped)'
436
+
437
+ lines = listed(json: false).lines
438
+
439
+ expect(lines[0]).to eq("#{'nightly'.ljust(30)} tag smoke, every day at 08:00 (notify) next Mon 9 Nov 08:00 passed?[2J 2026-11-07T08:01:00Z ok\n")
440
+ expect(lines[1]).to eq("#{'ghost'.ljust(30)} - - never run missing from schedules.yml\n")
441
+ expect(lines[2..]).to eq(["(cron: missed runs are skipped)\n"])
442
+ end
443
+ end
444
+ end
445
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../../../lib/utilities/schedules/selection'
5
+
6
+ RSpec.describe RubyRaider::Schedules::Selection do # rubocop:disable RSpec/FilePath
7
+ let(:tmp) { File.realpath(Dir.mktmpdir('schedules-selection')) }
8
+ let(:root) { File.join(tmp, 'project') }
9
+ let(:mac) { 'darwin24' }
10
+ let(:windows) { 'mingw32' }
11
+
12
+ before do
13
+ FileUtils.mkdir_p(File.join(root, 'spec/login'))
14
+ %w[spec/a_spec.rb spec/b_spec.rb spec/login/c_spec.rb].each { |file| File.write(File.join(root, file), '') }
15
+ end
16
+
17
+ after { FileUtils.rm_rf(tmp) }
18
+
19
+ def parse(files: [], tag: nil, host_os: mac) = described_class.parse(files: files, tag: tag, root: root, host_os: host_os)
20
+
21
+ def error_for(...)
22
+ parse(...)
23
+ nil
24
+ rescue RubyRaider::Schedules::Error => e
25
+ e.message
26
+ end
27
+
28
+ def load_error(hash, host_os: mac)
29
+ described_class.from_h(hash, root: root, host_os: host_os)
30
+ nil
31
+ rescue RubyRaider::Schedules::Error => e
32
+ e.message
33
+ end
34
+
35
+ describe '.parse' do
36
+ it 'selects all tests with no files and no tag' do
37
+ expect([parse.kind, parse.to_h]).to eq(['all', { 'kind' => 'all' }])
38
+ end
39
+
40
+ it 'selects one tag' do
41
+ expect(parse(tag: 'smoke:fast-1_x').to_h).to eq({ 'kind' => 'tag', 'tag' => 'smoke:fast-1_x' })
42
+ end
43
+
44
+ it 'selects files, removing duplicates and keeping their order' do
45
+ selection = parse(files: %w[spec/b_spec.rb spec/a_spec.rb spec/b_spec.rb spec/login/c_spec.rb])
46
+
47
+ expect(selection.to_h).to eq({ 'kind' => 'files', 'files' => %w[spec/b_spec.rb spec/a_spec.rb spec/login/c_spec.rb] })
48
+ end
49
+
50
+ it 'refuses a tag together with files' do
51
+ expect(error_for(files: %w[spec/a_spec.rb], tag: 'smoke')).to eq('Use --tag or spec files, not both')
52
+ end
53
+
54
+ it 'refuses a tag with a space, a semicolon, a newline or nothing in it' do
55
+ errors = ['smoke test', 'smoke;rm', "smoke\n;x", '', 'smoke$(id)', 1, "\xFF".dup.force_encoding('UTF-8')].map { |tag| error_for(tag: tag) }
56
+
57
+ expect(errors).to all(start_with('Invalid tag'))
58
+ end
59
+
60
+ it 'refuses paths that climb out, look like options or are absolute' do
61
+ hostile = ['../x_spec.rb', 'spec/../../x_spec.rb', '-rf_spec.rb', '/abs_spec.rb', "#{root}/spec/a_spec.rb"]
62
+
63
+ expect(hostile.map { |file| error_for(files: [file]) }).to all(start_with('Invalid spec file'))
64
+ end
65
+
66
+ it 'refuses a file that is not a _spec.rb, has a control character or is not text' do
67
+ bad = ['spec/a.rb', 'spec/a_spec.rb.bak', "spec/a\n_spec.rb", "spec/a\0_spec.rb", '', nil, 1, "spec/\xFF_spec.rb".b.force_encoding('UTF-8')]
68
+
69
+ expect(bad.map { |file| error_for(files: [file]) }).to all(start_with('Invalid spec file'))
70
+ end
71
+
72
+ it 'refuses a link that points outside the project' do
73
+ outside = File.join(tmp, 'outside_spec.rb')
74
+ File.write(outside, '')
75
+ File.symlink(outside, File.join(root, 'spec/link_spec.rb'))
76
+
77
+ expect(error_for(files: %w[spec/link_spec.rb])).to eq('spec/link_spec.rb points outside the project')
78
+ end
79
+
80
+ it 'refuses a folder link that points outside the project' do
81
+ FileUtils.mkdir_p(File.join(tmp, 'elsewhere'))
82
+ File.write(File.join(tmp, 'elsewhere/x_spec.rb'), '')
83
+ File.symlink(File.join(tmp, 'elsewhere'), File.join(root, 'spec/away'))
84
+
85
+ expect(error_for(files: %w[spec/away/x_spec.rb])).to eq('spec/away/x_spec.rb points outside the project')
86
+ end
87
+
88
+ it 'takes a link that stays inside the project' do
89
+ File.symlink(File.join(root, 'spec/a_spec.rb'), File.join(root, 'spec/alias_spec.rb'))
90
+
91
+ expect(parse(files: %w[spec/alias_spec.rb]).files).to eq(%w[spec/alias_spec.rb])
92
+ end
93
+
94
+ it 'refuses a file that is not there' do
95
+ expect(error_for(files: %w[spec/a_spec.rb spec/gone_spec.rb])).to eq('spec/gone_spec.rb not found')
96
+ end
97
+
98
+ it 'takes 200 files and refuses 201' do
99
+ files = Array.new(201) { |index| "spec/many/f#{index}_spec.rb" }
100
+ FileUtils.mkdir_p(File.join(root, 'spec/many'))
101
+ files.each { |file| File.write(File.join(root, file), '') }
102
+
103
+ expect([parse(files: files.first(200)).files.size, error_for(files: files)])
104
+ .to eq([200, 'Too many spec files (201): select at most 200, or use a tag'])
105
+ end
106
+
107
+ it 'counts files after removing duplicates' do
108
+ expect(parse(files: %w[spec/a_spec.rb] * 300).files).to eq(%w[spec/a_spec.rb])
109
+ end
110
+
111
+ it 'refuses %NAME% and glob characters in a file on Windows only' do
112
+ %w[a%PATH%_spec.rb a*_spec.rb a?_spec.rb a[1]_spec.rb a{b}_spec.rb].each { |file| File.write(File.join(root, 'spec', file), '') }
113
+ files = %w[spec/a%PATH%_spec.rb spec/a*_spec.rb spec/a?_spec.rb spec/a[1]_spec.rb spec/a{b}_spec.rb]
114
+
115
+ expect([files.map { |file| error_for(files: [file], host_os: windows) }, files.map { |file| error_for(files: [file]) }])
116
+ .to match([all(start_with('Invalid spec file')), all(be_nil)])
117
+ end
118
+
119
+ it 'refuses backslash climbs and drive paths on Windows' do
120
+ errors = ['spec\\..\\..\\x_spec.rb', 'C:/x_spec.rb', '\\\\host\\x_spec.rb'].map { |file| error_for(files: [file], host_os: windows) }
121
+
122
+ expect(errors).to all(start_with('Invalid spec file'))
123
+ end
124
+ end
125
+
126
+ describe '.from_h' do
127
+ it 'reads back what to_h wrote' do
128
+ [parse, parse(tag: 'smoke'), parse(files: %w[spec/a_spec.rb spec/login/c_spec.rb])].each do |selection|
129
+ expect(described_class.from_h(selection.to_h, root: root, host_os: mac).to_h).to eq(selection.to_h)
130
+ end
131
+ end
132
+
133
+ it 'loads a selection whose file is gone and names the missing file' do
134
+ selection = described_class.from_h({ 'kind' => 'files', 'files' => %w[spec/a_spec.rb spec/gone_spec.rb] }, root: root, host_os: mac)
135
+
136
+ expect([selection.missing_file(root), parse(files: %w[spec/a_spec.rb]).missing_file(root)]).to eq(['spec/gone_spec.rb', nil])
137
+ end
138
+
139
+ it 'refuses on load a file that exists but resolves outside the project' do
140
+ File.write(File.join(tmp, 'outside_spec.rb'), '')
141
+ File.symlink(File.join(tmp, 'outside_spec.rb'), File.join(root, 'spec/link_spec.rb'))
142
+
143
+ expect(load_error({ 'kind' => 'files', 'files' => %w[spec/link_spec.rb] })).to eq('spec/link_spec.rb points outside the project')
144
+ end
145
+
146
+ it 'refuses broken shapes as errors, never anything else' do
147
+ broken = [nil, 'all', [], {}, { 'kind' => 'some' }, { 'kind' => 'tag' }, { 'kind' => 'tag', 'tag' => 'a b' },
148
+ { 'kind' => 'files' }, { 'kind' => 'files', 'files' => [] }, { 'kind' => 'files', 'files' => 'spec/a_spec.rb' },
149
+ { 'kind' => 'files', 'files' => [1] }, { 'kind' => 'files', 'files' => ['../x_spec.rb'] },
150
+ { 'kind' => 'all', 'tag' => 'smoke' }, { 'kind' => 'tag', 'tag' => 'smoke', 'files' => %w[spec/a_spec.rb] },
151
+ { 'kind' => 'files', 'files' => Array.new(201) { |index| "spec/f#{index}_spec.rb" } }]
152
+
153
+ expect(broken.map { |hash| load_error(hash) }).to all(be_a(String))
154
+ end
155
+
156
+ it 'applies the Windows rules on load' do
157
+ expect(load_error({ 'kind' => 'files', 'files' => %w[spec/a%PATH%_spec.rb] }, host_os: windows)).to start_with('Invalid spec file')
158
+ end
159
+ end
160
+
161
+ describe '#check!' do
162
+ it 'raises for a missing file and returns itself otherwise' do
163
+ gone = described_class.from_h({ 'kind' => 'files', 'files' => %w[spec/gone_spec.rb] }, root: root, host_os: mac)
164
+ present = parse(files: %w[spec/a_spec.rb])
165
+
166
+ expect(present.check!(root)).to be(present)
167
+ expect { gone.check!(root) }.to raise_error(RubyRaider::Schedules::Error, 'spec/gone_spec.rb not found')
168
+ end
169
+ end
170
+
171
+ it 'is frozen' do
172
+ selection = parse(files: %w[spec/a_spec.rb])
173
+
174
+ expect([selection, selection.files, selection.files.first]).to all(be_frozen)
175
+ end
176
+ end