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,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'tmpdir'
5
+ require 'yaml'
6
+ require_relative '../../../lib/utilities/schedules/legacy'
7
+ require_relative '../../../lib/utilities/schedules/project'
8
+
9
+ # Every adapter here is a recording double, or a real one on a command double and a temp home: nothing
10
+ # reaches launchctl, crontab, systemctl or the real ~/Library/LaunchAgents.
11
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
12
+ RSpec.describe RubyRaider::Schedules::Legacy do # rubocop:disable RSpec/FilePath
13
+ # Answers list_ids from ids and remove from answers by name (true unless told: false, or an error to raise)
14
+ let(:fake_adapter_class) do
15
+ Class.new do
16
+ attr_reader :calls, :answers
17
+ attr_accessor :ids, :on_remove
18
+
19
+ def initialize(ids = [])
20
+ @calls = []
21
+ @ids = ids
22
+ @answers = {}
23
+ end
24
+
25
+ def list_ids(prefix)
26
+ @calls << [:list_ids, prefix]
27
+ ids.select { |id| id.start_with?(prefix) }
28
+ end
29
+
30
+ def remove(os_id)
31
+ @calls << [:remove, os_id]
32
+ on_remove&.call(os_id)
33
+ answer = answers.fetch(os_id.split('.').last, true)
34
+ raise answer if answer.is_a?(Exception)
35
+
36
+ answer
37
+ end
38
+ end
39
+ end
40
+
41
+ # Plays launchctl, crontab, systemctl and schtasks: records argv, keeps the crontab, answers from a table by
42
+ # verb (outputs: the stdout of a verb other than crontab -l)
43
+ let(:fake_runner_class) do
44
+ Class.new do
45
+ attr_reader :calls, :answers, :outputs
46
+ attr_accessor :table
47
+
48
+ def initialize
49
+ @calls = []
50
+ @answers = {}
51
+ @outputs = {}
52
+ end
53
+
54
+ def call(*argv, stdin_data: nil)
55
+ @calls << argv
56
+ key = argv.first == '/bin/launchctl' ? argv[1] : argv.reject { |arg| arg == '--user' }.first(2).join(' ')
57
+ stderr, status = answers.fetch(key) { key == 'crontab -l' && table.nil? ? ['no crontab for raider', 1] : ['', 0] }
58
+ self.table = stdin_data if key == 'crontab -' && status.zero?
59
+ stdout = key == 'crontab -l' ? table.to_s : outputs.fetch(key, '')
60
+ RubyRaider::Schedules::Os::CommandRunner::Result.new(stdout: stdout, stderr: stderr, exitstatus: status)
61
+ end
62
+ end
63
+ end
64
+
65
+ let(:tmp) { File.realpath(Dir.mktmpdir('schedules-legacy')) }
66
+ let(:home) { File.join(tmp, 'home') }
67
+ let(:agents) { File.join(home, 'Library', 'LaunchAgents') }
68
+ let(:a_dir) { project_dir('a') }
69
+ let(:b_dir) { project_dir('b') }
70
+ let(:err) { StringIO.new }
71
+ let(:runner) { fake_runner_class.new }
72
+ let(:kind) { RubyRaider::Schedules::Kind::FLOW_LEGACY }
73
+ let(:old_rule) { described_class::OldRule }
74
+
75
+ after { FileUtils.rm_rf(tmp) }
76
+
77
+ def project_dir(name)
78
+ dir = File.join(tmp, name)
79
+ FileUtils.mkdir_p(dir)
80
+ dir
81
+ end
82
+
83
+ def project(dir) = RubyRaider::Schedules::Project.new(dir)
84
+
85
+ def os_id(dir, name) = old_rule.os_id_for(dir, name)
86
+
87
+ def yml(dir) = File.join(dir, '.raider', 'flows', 'schedules.yml')
88
+
89
+ # A 4.1 schedules.yml; each entry a name and its os_id for this project unless given
90
+ def write_yml(dir, *names, entries: names.map { |name| { 'name' => name, 'every' => 'day', 'at' => '08:00', 'os_id' => os_id(dir, name) } })
91
+ FileUtils.mkdir_p(File.dirname(yml(dir)))
92
+ File.write(yml(dir), YAML.dump({ 'schedules' => entries }))
93
+ end
94
+
95
+ def cleanup(dir, adapter, **options) = described_class.new(project(dir), adapter: adapter, err: err).cleanup(**options)
96
+
97
+ def removed(*names) = names.map { |name| [:remove, os_id(a_dir, name)] }
98
+
99
+ def removed_notice(*names)
100
+ one = names.size == 1
101
+ "Removed #{one ? '1 schedule' : "#{names.size} schedules"} made with Ruby Raider 4.1 (#{names.join(', ')}); " \
102
+ "#{one ? 'it' : 'they'} can't run in 4.2. Add #{one ? 'it' : 'them'} again with raider schedule add."
103
+ end
104
+
105
+ describe 'with launchd' do
106
+ let(:launchd) { RubyRaider::Schedules::Os::Launchd.new(kind: kind, runner: runner, home: home, uid: 501) }
107
+
108
+ def plist(dir, name) = File.join(agents, "#{os_id(dir, name)}.plist")
109
+
110
+ def write_plist(path, text = 'x')
111
+ FileUtils.mkdir_p(agents)
112
+ File.write(path, text)
113
+ end
114
+
115
+ it "removes only this project's entry when another project has one with the same name" do
116
+ write_yml(a_dir, 'report')
117
+ write_plist(plist(a_dir, 'report'))
118
+ write_plist(plist(b_dir, 'report'), "b's plist")
119
+
120
+ notice = cleanup(a_dir, launchd)
121
+
122
+ expect(notice).to eq(removed_notice('report'))
123
+ expect(err.string).to eq("#{removed_notice('report')}\n")
124
+ expect([File.exist?(plist(a_dir, 'report')), File.read(plist(b_dir, 'report')), File.exist?(yml(a_dir))]).to eq([false, "b's plist", false])
125
+ expect(runner.calls).to eq([['/bin/launchctl', 'bootout', "gui/501/#{os_id(a_dir, 'report')}"]])
126
+ end
127
+
128
+ it 'never uses an os_id stored in the yml: an entry copied from elsewhere is skipped' do
129
+ foreign = 'dev.rubyraider.flow.1255a248a3.tredssd'
130
+ write_yml(a_dir, entries: [{ 'name' => 'tredssd', 'os_id' => foreign }])
131
+ write_plist(File.join(agents, "#{foreign}.plist"), 'live')
132
+ write_plist(plist(a_dir, 'tredssd'))
133
+
134
+ notice = cleanup(a_dir, launchd)
135
+
136
+ expect(notice).to eq(removed_notice('tredssd'))
137
+ expect(runner.calls.flatten.grep(/1255a248a3/)).to be_empty
138
+ expect(File.read(File.join(agents, "#{foreign}.plist"))).to eq('live')
139
+ end
140
+ end
141
+
142
+ it 'never passes a stored os_id to remove' do
143
+ write_yml(a_dir, entries: [{ 'name' => 'tredssd', 'os_id' => 'dev.rubyraider.flow.1255a248a3.tredssd' }])
144
+ adapter = fake_adapter_class.new(['dev.rubyraider.flow.1255a248a3.tredssd'])
145
+
146
+ expect(cleanup(a_dir, adapter)).to be_nil
147
+ expect(adapter.calls.flatten.grep(/1255a248a3/)).to be_empty
148
+ end
149
+
150
+ it "keeps the yml of a moved project, whose entries carry the old path's os_ids" do
151
+ old_dir = project_dir('old')
152
+ write_yml(a_dir, entries: %w[alpha beta].map { |name| { 'name' => name, 'os_id' => os_id(old_dir, name) } })
153
+ adapter = fake_adapter_class.new([os_id(old_dir, 'alpha'), os_id(old_dir, 'beta')])
154
+
155
+ expect([cleanup(a_dir, adapter), err.string, File.exist?(yml(a_dir))]).to eq([nil, '', true])
156
+ expect(adapter.calls.map(&:first)).to eq([:list_ids])
157
+ end
158
+
159
+ describe 'with cron' do
160
+ let(:cron) { RubyRaider::Schedules::Os::Cron.new(kind: kind, runner: runner) }
161
+
162
+ def pair(dir, name)
163
+ "# raider-flow #{os_id(dir, name)}\n" \
164
+ "0 8 * * * cd #{dir} && /usr/bin/env /r /b exec raider flow run #{name} --project #{dir} --trigger schedule >> /l 2>&1\n"
165
+ end
166
+
167
+ it "removes only this project's tagged pair; every other byte stays" do
168
+ runner.table = "#{pair(b_dir, 'report')}MAILTO=\"\"\n5 * * * * /usr/bin/backup --keep\n#{pair(a_dir, 'report')}"
169
+
170
+ expect(cleanup(a_dir, cron)).to eq(removed_notice('report'))
171
+ expect(runner.table).to eq("#{pair(b_dir, 'report')}MAILTO=\"\"\n5 * * * * /usr/bin/backup --keep\n")
172
+ end
173
+ end
174
+
175
+ describe 'on Linux, with systemd' do
176
+ let(:units) { File.join(home, '.config', 'systemd', 'user') }
177
+ let(:linux) do
178
+ systemd = RubyRaider::Schedules::Os::Systemd.new(kind: kind, runner: runner, home: home, config_home: nil)
179
+ RubyRaider::Schedules::Os::Linux.new(kind: kind, runner: runner, systemd: systemd)
180
+ end
181
+
182
+ def unit(dir, name, type) = File.join(units, "#{os_id(dir, name)}.#{type}")
183
+
184
+ it "removes only this project's units when another project has one with the same name" do
185
+ write_yml(a_dir, 'report')
186
+ FileUtils.mkdir_p(units)
187
+ [a_dir, b_dir].product(%w[timer service]).each { |dir, type| File.write(unit(dir, 'report', type), "#{dir} #{type}") }
188
+
189
+ expect(cleanup(a_dir, linux)).to eq(removed_notice('report'))
190
+ expect(runner.calls.flatten.grep(/dev\.rubyraider/)).to eq(["#{os_id(a_dir, 'report')}.timer"])
191
+ expect(%w[timer service].map { |type| File.exist?(unit(a_dir, 'report', type)) }).to eq([false, false])
192
+ expect(%w[timer service].map { |type| File.read(unit(b_dir, 'report', type)) }).to eq(["#{b_dir} timer", "#{b_dir} service"])
193
+ end
194
+ end
195
+
196
+ describe 'with Task Scheduler' do
197
+ let(:schtasks) { 'C:\\Windows\\System32\\schtasks.exe' }
198
+ let(:task_scheduler) { RubyRaider::Schedules::Os::TaskScheduler.new(kind: kind, runner: runner, schtasks: schtasks) }
199
+
200
+ def task(dir, name) = "\\RubyRaider\\#{os_id(dir, name)}"
201
+
202
+ it "deletes only this project's task when another project has one with the same name" do
203
+ write_yml(a_dir, 'report')
204
+ runner.outputs["#{schtasks} /Query"] = [task(b_dir, 'report'), task(a_dir, 'report')]
205
+ .map { |name| %("#{name}","11/10/2026 8:00:00 AM","Ready"\r\n) }.join
206
+
207
+ expect(cleanup(a_dir, task_scheduler)).to eq(removed_notice('report'))
208
+ expect(runner.calls.select { |argv| argv[1] == '/Delete' }).to eq([[schtasks, '/Delete', '/TN', task(a_dir, 'report'), '/F']])
209
+ end
210
+ end
211
+
212
+ describe 'on Linux, cron with systemd unit files left behind' do
213
+ let(:units) { File.join(home, '.config', 'systemd', 'user') }
214
+ let(:linux) do
215
+ systemd = RubyRaider::Schedules::Os::Systemd.new(kind: kind, runner: runner, home: home, config_home: nil)
216
+ RubyRaider::Schedules::Os::Linux.new(kind: kind, runner: runner, systemd: systemd)
217
+ end
218
+ let(:stranded) do
219
+ 'Could not remove 1 schedule made with Ruby Raider 4.1 (report: This schedule is a systemd timer; run raider schedule list ' \
220
+ 'from a session where systemctl --user works); it is tried again next time.'
221
+ end
222
+
223
+ before do
224
+ runner.answers['systemctl show-environment'] = ['Failed to connect to bus', 1]
225
+ FileUtils.mkdir_p(units)
226
+ File.write(File.join(units, "#{os_id(a_dir, 'report')}.timer"), 'x')
227
+ end
228
+
229
+ it 'reports the failure, keeps the yml, and tries again next time' do
230
+ write_yml(a_dir, 'report')
231
+
232
+ first = cleanup(a_dir, linux)
233
+ FileUtils.rm_f(yml(a_dir))
234
+ second = cleanup(a_dir, linux)
235
+
236
+ expect([first, second]).to eq([stranded, stranded])
237
+ expect(err.string.lines).to eq(["#{stranded}\n", "#{stranded}\n"])
238
+ expect(File.exist?(File.join(units, "#{os_id(a_dir, 'report')}.timer"))).to be(true)
239
+ end
240
+
241
+ it 'keeps the yml' do
242
+ write_yml(a_dir, 'report')
243
+ cleanup(a_dir, linux)
244
+
245
+ expect(File.exist?(yml(a_dir))).to be(true)
246
+ end
247
+ end
248
+
249
+ describe 'a failed removal' do
250
+ it 'keeps the yml; a later run deletes it and reports only what it removed then' do
251
+ write_yml(a_dir, 'alpha', 'beta')
252
+ adapter = fake_adapter_class.new
253
+ adapter.answers['beta'] = RubyRaider::Schedules::Error.new("busy\nnow")
254
+
255
+ first = cleanup(a_dir, adapter)
256
+ kept = File.exist?(yml(a_dir))
257
+ adapter.answers.replace('alpha' => false)
258
+ second = cleanup(a_dir, adapter)
259
+
260
+ expect(first).to eq("#{removed_notice('alpha')} Could not remove 1 schedule made with Ruby Raider 4.1 (beta: busy?now); it is tried again next time.")
261
+ expect([kept, second, File.exist?(yml(a_dir))]).to eq([true, removed_notice('beta'), false])
262
+ expect(err.string.lines.size).to eq(2)
263
+ end
264
+
265
+ it 'counts in the plural' do
266
+ write_yml(a_dir, 'alpha', 'beta')
267
+ adapter = fake_adapter_class.new
268
+ adapter.answers.replace('alpha' => RubyRaider::Schedules::Error.new('x'), 'beta' => RubyRaider::Schedules::Error.new('y'))
269
+
270
+ expect(cleanup(a_dir, adapter))
271
+ .to eq('Could not remove 2 schedules made with Ruby Raider 4.1 (alpha: x, beta: y); they are tried again next time.')
272
+ end
273
+
274
+ it 'names every schedule it removed, from the yml and the OS, sorted' do
275
+ write_yml(a_dir, 'beta')
276
+ adapter = fake_adapter_class.new([os_id(a_dir, 'alpha'), os_id(a_dir, 'beta')])
277
+
278
+ expect(cleanup(a_dir, adapter)).to eq(removed_notice('alpha', 'beta'))
279
+ expect(adapter.calls.drop(1)).to eq(removed('alpha', 'beta'))
280
+ end
281
+ end
282
+
283
+ describe 'with nothing to clean' do
284
+ it 'is nil, prints nothing, creates or deletes nothing, and only enumerates' do
285
+ adapter = fake_adapter_class.new
286
+ FileUtils.mkdir_p(File.join(a_dir, 'flows'))
287
+ before = Dir.glob('**/*', File::FNM_DOTMATCH, base: tmp).sort
288
+
289
+ expect(cleanup(a_dir, adapter)).to be_nil
290
+ expect([err.string, Dir.glob('**/*', File::FNM_DOTMATCH, base: tmp).sort]).to eq(['', before])
291
+ expect(adapter.calls).to eq([[:list_ids, old_rule.os_id_prefix_for(a_dir)]])
292
+ end
293
+
294
+ it 'is nil when the entries are already gone, and deletes the yml' do
295
+ write_yml(a_dir, 'report')
296
+ adapter = fake_adapter_class.new
297
+ adapter.answers['report'] = false
298
+
299
+ expect([cleanup(a_dir, adapter), err.string, File.exist?(yml(a_dir))]).to eq([nil, '', false])
300
+ end
301
+
302
+ it 'deletes an empty schedules file' do
303
+ results = ['', "schedules: []\n"].map do |text|
304
+ FileUtils.mkdir_p(File.dirname(yml(a_dir)))
305
+ File.write(yml(a_dir), text)
306
+ [cleanup(a_dir, fake_adapter_class.new), File.exist?(yml(a_dir))]
307
+ end
308
+
309
+ expect([results, err.string]).to eq([[[nil, false], [nil, false]], ''])
310
+ end
311
+
312
+ it 'does nothing where there is no OS scheduler' do
313
+ write_yml(a_dir, 'report')
314
+ allow(RubyRaider::Schedules).to receive(:adapter).and_raise(RubyRaider::Schedules::Error, 'Scheduling 4.1 schedules is not supported on solaris')
315
+
316
+ expect([described_class.new(project(a_dir), err: err).cleanup, err.string, File.exist?(yml(a_dir))]).to eq([nil, '', true])
317
+ expect(RubyRaider::Schedules).to have_received(:adapter).with(kind: kind)
318
+ end
319
+ end
320
+
321
+ describe 'a yml it does not read' do
322
+ let(:adapter) { fake_adapter_class.new }
323
+
324
+ it 'skips .raider/flows when it is a link to another folder, and leaves what is there' do
325
+ outside = project_dir('outside')
326
+ write_yml(outside, entries: [{ 'name' => 'report', 'os_id' => os_id(a_dir, 'report') }])
327
+ FileUtils.mkdir_p(File.join(a_dir, '.raider'))
328
+ File.symlink(File.join(outside, '.raider', 'flows'), File.join(a_dir, '.raider', 'flows'))
329
+
330
+ expect(cleanup(a_dir, adapter)).to be_nil
331
+ expect([adapter.calls.map(&:first), File.exist?(yml(outside))]).to eq([[:list_ids], true])
332
+ end
333
+
334
+ it 'skips a schedules.yml that is a link, and leaves it' do
335
+ outside = project_dir('outside')
336
+ write_yml(outside, entries: [{ 'name' => 'report', 'os_id' => os_id(a_dir, 'report') }])
337
+ FileUtils.mkdir_p(File.dirname(yml(a_dir)))
338
+ File.symlink(yml(outside), yml(a_dir))
339
+
340
+ expect(cleanup(a_dir, adapter)).to be_nil
341
+ expect([adapter.calls.map(&:first), File.symlink?(yml(a_dir)), File.exist?(yml(outside))]).to eq([[:list_ids], true, true])
342
+ end
343
+
344
+ it 'skips entries that are not 4.1 ones, and a file that is too big or not YAML' do
345
+ write_yml(a_dir, entries: [{ 'name' => 123, 'os_id' => os_id(a_dir, '123') }, { 'name' => 'env', 'os_id' => os_id(a_dir, 'env') },
346
+ { 'name' => 'Caps', 'os_id' => os_id(a_dir, 'Caps') }, { 'name' => 'nameless' }, 'report'])
347
+ results = [[cleanup(a_dir, adapter), File.exist?(yml(a_dir))]]
348
+ ["schedules: [\n", "schedules:\n - name: #{'a' * 1_000_001}\n", "schedules: report\n", "- report\n"].each do |text|
349
+ File.write(yml(a_dir), text)
350
+ results << [cleanup(a_dir, adapter), File.exist?(yml(a_dir))]
351
+ end
352
+
353
+ expect(results).to all(eq([nil, true]))
354
+ expect(adapter.calls.map(&:first).uniq).to eq([:list_ids])
355
+ end
356
+ end
357
+
358
+ it "leaves the user's files" do
359
+ write_yml(a_dir, 'report')
360
+ kept = %w[flows/login.yml flow-results/run.json .raider/flows/env .raider/flows/report/scheduler.log .raider/tests/flows/x.rb]
361
+ kept.each do |path|
362
+ FileUtils.mkdir_p(File.dirname(File.join(a_dir, path)))
363
+ File.write(File.join(a_dir, path), path)
364
+ end
365
+
366
+ expect(cleanup(a_dir, fake_adapter_class.new)).to eq(removed_notice('report'))
367
+ expect(kept.map { |path| File.read(File.join(a_dir, path)) }).to eq(kept)
368
+ expect(File.exist?(yml(a_dir))).to be(false)
369
+ end
370
+
371
+ it 'turns an unexpected error into one printed line, never raising' do
372
+ write_yml(a_dir, 'report')
373
+ adapter = fake_adapter_class.new
374
+ adapter.answers['report'] = RuntimeError.new("odd\e[2J")
375
+
376
+ expect(cleanup(a_dir, adapter)).to eq('Could not remove the schedules made with Ruby Raider 4.1: odd?[2J')
377
+ expect([err.string, File.exist?(yml(a_dir))]).to eq(["Could not remove the schedules made with Ruby Raider 4.1: odd?[2J\n", true])
378
+ end
379
+
380
+ describe 'its own entry (last)' do
381
+ let(:events) { [] }
382
+ let(:recording_err) do
383
+ log = events
384
+ Class.new do
385
+ define_method(:puts) { |text| log << [:puts, text] }
386
+ define_method(:flush) { log << [:flush] }
387
+ end.new
388
+ end
389
+ let(:adapter) do
390
+ fake_adapter_class.new([os_id(a_dir, 'report'), os_id(a_dir, 'other')]).tap do |double|
391
+ log = events
392
+ path = yml(a_dir)
393
+ double.on_remove = ->(id) { log << [:remove, id.split('.').last, File.exist?(path)] }
394
+ end
395
+ end
396
+
397
+ def own_cleanup = described_class.new(project(a_dir), adapter: adapter, err: recording_err).cleanup(last: 'report')
398
+
399
+ it 'is counted, and removed after the notice is out and the yml deleted' do
400
+ write_yml(a_dir, 'report')
401
+
402
+ notice = own_cleanup
403
+
404
+ expect(notice).to eq(removed_notice('other', 'report'))
405
+ expect(events).to eq([[:remove, 'other', true], [:puts, notice], [:flush], [:remove, 'report', false]])
406
+ end
407
+
408
+ it 'says so on a second line when it can not be removed' do
409
+ adapter.answers['report'] = RubyRaider::Schedules::Error.new('denied')
410
+
411
+ own_cleanup
412
+
413
+ expect(events.select { |event| event.first == :puts }.map(&:last)).to eq([removed_notice('other', 'report'), 'Could not remove report: denied'])
414
+ end
415
+
416
+ it 'is not removed when it is not one of the names' do
417
+ adapter.ids = [os_id(a_dir, 'other')]
418
+
419
+ expect(described_class.new(project(a_dir), adapter: adapter, err: err).cleanup(last: 'report')).to eq(removed_notice('other'))
420
+ expect(adapter.calls.drop(1)).to eq(removed('other'))
421
+ end
422
+ end
423
+
424
+ it 'prints no flow word' do
425
+ write_yml(a_dir, 'alpha', 'beta')
426
+ adapter = fake_adapter_class.new
427
+ adapter.answers['beta'] = RubyRaider::Schedules::Error.new('busy')
428
+ cleanup(a_dir, adapter)
429
+
430
+ expect(err.string).not_to match(/\bflows?\b/i)
431
+ end
432
+ end
433
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'tmpdir'
5
+ require_relative '../../../lib/ruby_raider'
6
+ require_relative '../../../lib/utilities/schedules/legacy_stub'
7
+ require_relative '../../../lib/utilities/schedules/notifier'
8
+
9
+ # Through RubyRaider::Raider.start, as `raider flow ...` from a 4.1 OS entry reaches it. Legacy always gets a
10
+ # recording adapter: nothing reaches the OS scheduler.
11
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
12
+ RSpec.describe RubyRaider::Schedules::LegacyStub do # rubocop:disable RSpec/FilePath
13
+ let(:fake_adapter_class) do
14
+ Class.new do
15
+ attr_reader :ids, :removed
16
+
17
+ def initialize(ids)
18
+ @ids = ids
19
+ @removed = []
20
+ end
21
+
22
+ def list_ids(prefix) = ids.select { |id| id.start_with?(prefix) }
23
+
24
+ def remove(os_id)
25
+ @removed << os_id.split('.').last
26
+ true
27
+ end
28
+ end
29
+ end
30
+ let(:dir) { File.realpath(Dir.mktmpdir('legacy-stub')) }
31
+ let(:adapter) { fake_adapter_class.new(%w[other tredssd].map { |name| os_id(name) }) }
32
+ let(:removed_line) { 'This command was removed in Ruby Raider 4.2. To run tests on a schedule, use raider schedule add.' }
33
+ let(:notice) do
34
+ "Removed 2 schedules made with Ruby Raider 4.1 (other, tredssd); they can't run in 4.2. Add them again with raider schedule add."
35
+ end
36
+
37
+ before do
38
+ File.write(File.join(dir, 'Gemfile'), "source 'https://rubygems.org'\n")
39
+ allow(RubyRaider::Schedules::Legacy).to receive(:new).and_wrap_original do |original, project, **options|
40
+ original.call(project, adapter: adapter, **options)
41
+ end
42
+ expect(RubyRaider::Schedules::Notifier).not_to receive(:new) # rubocop:disable RSpec/ExpectInHook
43
+ end
44
+
45
+ after { FileUtils.rm_rf(dir) }
46
+
47
+ def os_id(name) = RubyRaider::Schedules::Legacy::OldRule.os_id_for(dir, name)
48
+
49
+ # [stdout, stderr, exit code or nil when it did not exit]
50
+ def raider(*args)
51
+ out = StringIO.new
52
+ err = StringIO.new
53
+ stdout = $stdout
54
+ stderr = $stderr
55
+ $stdout = out
56
+ $stderr = err
57
+ code = begin
58
+ RubyRaider::Raider.start(args)
59
+ nil
60
+ rescue SystemExit => e
61
+ e.status
62
+ ensure
63
+ $stdout = stdout
64
+ $stderr = stderr
65
+ end
66
+ [out.string, err.string, code]
67
+ end
68
+
69
+ it "removes the project's 4.1 schedules, its own last, and exits 2 with the notice on stderr" do
70
+ result = raider('flow', 'run', 'tredssd', '--project', dir, '--trigger', 'schedule', '--retries', '1', '--notify', '--input', 'a=b')
71
+
72
+ expect(result).to eq(['', "#{notice}\n", 2])
73
+ expect(adapter.removed).to eq(%w[other tredssd])
74
+ end
75
+
76
+ it 'takes --project=VALUE, and f' do
77
+ result = raider('f', 'run', 'tredssd', "--project=#{dir}", '--trigger', 'schedule')
78
+
79
+ expect(result).to eq(['', "#{notice}\n", 2])
80
+ end
81
+
82
+ it 'takes the first --project only' do
83
+ result = raider('flow', 'run', 'tredssd', '--project', dir, '--project', '/nonexistent')
84
+
85
+ expect(result).to eq(['', "#{notice}\n", 2])
86
+ end
87
+
88
+ it 'still cleans up with invalid UTF-8 in another argument' do
89
+ result = raider('flow', 'run', "a\xFF", '--project', dir, '--input', "x=\xFF")
90
+
91
+ expect(result).to eq(['', "#{notice}\n", 2])
92
+ end
93
+
94
+ it 'says none was found for the project when there is nothing to remove' do
95
+ adapter.ids.clear
96
+
97
+ result = raider('flow', 'run', 'tredssd', '--project', dir, '--trigger', 'schedule')
98
+
99
+ expect(result).to eq(['', "This schedule was made with Ruby Raider 4.1 and can't run in 4.2, and none was found for #{dir} " \
100
+ "to remove: see Upgrading from 4.1 in the README.\n", 2])
101
+ end
102
+
103
+ it 'exits 2 even when the cleanup misbehaves' do
104
+ allow(RubyRaider::Schedules::Legacy).to receive(:new).and_raise(RuntimeError, 'odd')
105
+
106
+ expect(raider('flow', 'run', 'tredssd', '--project', dir).last).to eq(2)
107
+ end
108
+
109
+ describe 'without a usable project' do
110
+ let(:no_gemfile) { File.realpath(Dir.mktmpdir('legacy-stub-bare')) }
111
+
112
+ after { FileUtils.rm_rf(no_gemfile) }
113
+
114
+ it 'says the command was removed, exits 2 and cleans up nothing, whatever the argv' do
115
+ gemfile_dir = File.realpath(Dir.mktmpdir('legacy-stub-gemfile-dir'))
116
+ FileUtils.mkdir_p(File.join(gemfile_dir, 'Gemfile'))
117
+ argvs = [%w[flow], %w[f], %w[flow --help], %w[flow help], %w[flow run tredssd --project], ['flow', 'run', 'tredssd', '--project', "#{dir}\xFF"],
118
+ %w[flow run tredssd --project relative/dir], %w[flow run tredssd --project /nonexistent/raider],
119
+ ['flow', 'run', 'tredssd', '--project', no_gemfile], ['flow', 'run', 'tredssd', '--project', gemfile_dir],
120
+ ['flow', 'run', 'tredssd', '--project', "#{dir}\0"], %w[flow run tredssd --retries 1 --notify --input a=b]]
121
+
122
+ results = argvs.map { |argv| raider(*argv) }
123
+
124
+ expect(results).to all(eq(['', "#{removed_line}\n", 2]))
125
+ expect(RubyRaider::Schedules::Legacy).not_to have_received(:new)
126
+ ensure
127
+ FileUtils.rm_rf(gemfile_dir)
128
+ end
129
+
130
+ it 'never takes the current folder' do
131
+ result = Dir.chdir(dir) { raider('flow', 'run', 'tredssd', '--trigger', 'schedule') }
132
+
133
+ expect(result).to eq(['', "#{removed_line}\n", 2])
134
+ expect(adapter.removed).to be_empty
135
+ end
136
+ end
137
+
138
+ it 'is not in raider help' do
139
+ out, = raider('help')
140
+
141
+ expect(out).not_to match(/\bflows?\b/)
142
+ end
143
+ end
144
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'tmpdir'
4
- require_relative '../../../lib/utilities/flows/next_run'
4
+ require_relative '../../../lib/utilities/schedules/next_run'
5
+ require_relative '../../../lib/utilities/schedules/schedule'
5
6
 
6
- # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
7
- RSpec.describe RubyRaider::Flows::NextRun do # rubocop:disable RSpec/FilePath
7
+ RSpec.describe RubyRaider::Schedules::NextRun do # rubocop:disable RSpec/FilePath
8
8
  let(:dir) { File.realpath(Dir.mktmpdir('flows-next-run')) }
9
- let(:project) { RubyRaider::Flows::Project.new(dir) }
9
+ let(:project) { RubyRaider::Schedules::Project.new(dir) }
10
10
 
11
11
  around do |example|
12
12
  zone = ENV.fetch('TZ', nil)
@@ -19,7 +19,7 @@ RSpec.describe RubyRaider::Flows::NextRun do # rubocop:disable RSpec/FilePath
19
19
  after { FileUtils.rm_rf(dir) }
20
20
 
21
21
  def rule(every, at = '08:00', on: nil)
22
- RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: every, at: at, on: on)
22
+ RubyRaider::Schedules::Schedule.build(project: project, name: 'report', every: every, at: at, on: on)
23
23
  end
24
24
 
25
25
  def next_after(schedule, *local)
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'stringio'
4
- require_relative '../../../lib/utilities/flows/notifier'
4
+ require_relative '../../../lib/utilities/schedules/notifier'
5
5
 
6
- # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
7
- RSpec.describe RubyRaider::Flows::Notifier do # rubocop:disable RSpec/FilePath
6
+ RSpec.describe RubyRaider::Schedules::Notifier do # rubocop:disable RSpec/FilePath
8
7
  # Records each command instead of running it: no real osascript, notify-send or PowerShell
9
8
  let(:runner) do
10
9
  Class.new do
@@ -18,7 +17,7 @@ RSpec.describe RubyRaider::Flows::Notifier do # rubocop:disable RSpec/FilePath
18
17
 
19
18
  def call(*argv, env: {}, timeout: nil, **)
20
19
  @calls << { argv: argv, env: env, timeout: timeout }
21
- RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: '', stderr: 'no notification server', exitstatus: exitstatus)
20
+ RubyRaider::Schedules::Os::CommandRunner::Result.new(stdout: '', stderr: 'no notification server', exitstatus: exitstatus)
22
21
  end
23
22
  end.new
24
23
  end
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rbconfig'
4
- require_relative '../../../../lib/utilities/flows/os/command_runner'
4
+ require_relative '../../../../lib/utilities/schedules/os/command_runner'
5
5
 
6
- # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
7
- RSpec.describe RubyRaider::Flows::Os::CommandRunner do # rubocop:disable RSpec/FilePath
6
+ RSpec.describe RubyRaider::Schedules::Os::CommandRunner do # rubocop:disable RSpec/FilePath
8
7
  let(:runner) { described_class.new }
9
8
  let(:ruby) { RbConfig.ruby }
10
9