ruby_raider 4.0.1 → 4.1.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +59 -1
  3. data/RELEASE.md +12 -0
  4. data/lib/commands/flow_commands.rb +104 -0
  5. data/lib/generators/templates/flows/flow_helper.tt +42 -0
  6. data/lib/generators/templates/flows/run.tt +332 -0
  7. data/lib/ruby_raider.rb +16 -0
  8. data/lib/utilities/flows/next_run.rb +61 -0
  9. data/lib/utilities/flows/notifier.rb +76 -0
  10. data/lib/utilities/flows/os/command_runner.rb +73 -0
  11. data/lib/utilities/flows/os/cron.rb +137 -0
  12. data/lib/utilities/flows/os/launchd.rb +164 -0
  13. data/lib/utilities/flows/os/linux.rb +63 -0
  14. data/lib/utilities/flows/os/systemd.rb +178 -0
  15. data/lib/utilities/flows/os/task_scheduler.rb +179 -0
  16. data/lib/utilities/flows/project.rb +170 -0
  17. data/lib/utilities/flows/run_log.rb +61 -0
  18. data/lib/utilities/flows/runner.rb +206 -0
  19. data/lib/utilities/flows/schedule.rb +221 -0
  20. data/lib/utilities/flows/schedule_store.rb +135 -0
  21. data/lib/utilities/flows/scheduler.rb +187 -0
  22. data/lib/version +1 -1
  23. data/sig/commands/flow_commands.rbs +13 -0
  24. data/sig/ruby_raider.rbs +2 -0
  25. data/sig/utilities/flows/next_run.rbs +15 -0
  26. data/sig/utilities/flows/notifier.rbs +29 -0
  27. data/sig/utilities/flows/os/command_runner.rbs +27 -0
  28. data/sig/utilities/flows/os/cron.rbs +34 -0
  29. data/sig/utilities/flows/os/launchd.rbs +45 -0
  30. data/sig/utilities/flows/os/linux.rbs +27 -0
  31. data/sig/utilities/flows/os/systemd.rbs +43 -0
  32. data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
  33. data/sig/utilities/flows/project.rbs +46 -0
  34. data/sig/utilities/flows/run_log.rbs +22 -0
  35. data/sig/utilities/flows/runner.rbs +35 -0
  36. data/sig/utilities/flows/schedule.rbs +61 -0
  37. data/sig/utilities/flows/schedule_store.rbs +34 -0
  38. data/sig/utilities/flows/scheduler.rbs +42 -0
  39. data/spec/commands/flow_commands_spec.rb +128 -0
  40. data/spec/generators/flow_helper_spec.rb +40 -0
  41. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  42. data/spec/integration/end_to_end_spec.rb +63 -0
  43. data/spec/integration/flows/flow_project.rb +62 -0
  44. data/spec/integration/flows/run_command_spec.rb +91 -0
  45. data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
  46. data/spec/utilities/flows/next_run_spec.rb +119 -0
  47. data/spec/utilities/flows/notifier_spec.rb +121 -0
  48. data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
  49. data/spec/utilities/flows/os/cron_spec.rb +287 -0
  50. data/spec/utilities/flows/os/launchd_spec.rb +293 -0
  51. data/spec/utilities/flows/os/linux_spec.rb +149 -0
  52. data/spec/utilities/flows/os/systemd_spec.rb +329 -0
  53. data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
  54. data/spec/utilities/flows/project_spec.rb +178 -0
  55. data/spec/utilities/flows/run_log_spec.rb +47 -0
  56. data/spec/utilities/flows/runner_spec.rb +313 -0
  57. data/spec/utilities/flows/schedule_spec.rb +233 -0
  58. data/spec/utilities/flows/schedule_store_spec.rb +137 -0
  59. data/spec/utilities/flows/scheduler_spec.rb +329 -0
  60. metadata +53 -2
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'open3'
6
+ require 'time'
7
+ require 'timeout'
8
+ require 'tmpdir'
9
+ require_relative 'flow_project'
10
+
11
+ # Runs flows/support/run.rb and flows/flow_helper.rb, as `raider flow init` writes them, under a real
12
+ # rspec process (see FlowProject: no browser, no raider_reporter).
13
+ RSpec.describe 'flow run lifecycle' do
14
+ flows = {
15
+ 'ok' => <<~RUBY,
16
+ require_relative 'flow_helper'
17
+
18
+ RSpec.describe 'ok' do
19
+ it 'runs every step' do
20
+ driver
21
+ puts "headless=\#{ENV.fetch('HEADLESS', 'unset')} who=\#{flow_input('who', 'nobody')}"
22
+ puts "secret loaded=\#{ENV['RAIDER_SECRET_TOKEN'] == 'hunter2'}"
23
+ end
24
+ end
25
+ RUBY
26
+ 'broken' => <<~RUBY,
27
+ require_relative 'flow_helper'
28
+
29
+ RSpec.describe 'broken' do
30
+ it 'downloads the report' do
31
+ driver
32
+ driver
33
+ raise 'no such element: { id: "export" } (waited 10s)'
34
+ end
35
+ end
36
+ RUBY
37
+ 'recorded' => <<~RUBY,
38
+ require_relative 'flow_helper'
39
+
40
+ RSpec.describe 'recorded' do
41
+ it 'exports' do
42
+ raise 'no such element'
43
+ end
44
+ end
45
+ RUBY
46
+ 'leaky' => <<~RUBY,
47
+ require_relative 'flow_helper'
48
+
49
+ RSpec.describe 'leaky' do
50
+ it "types \#{ENV.fetch('RAIDER_SECRET_TOKEN', 'x')} somewhere" do
51
+ raise 'failed'
52
+ end
53
+ end
54
+ RUBY
55
+ 'env' => <<~RUBY,
56
+ require_relative 'flow_helper'
57
+
58
+ RSpec.describe('env') { it('runs') { puts 'ran' } }
59
+ RUBY
60
+ 'long' => <<~RUBY,
61
+ require_relative 'flow_helper'
62
+
63
+ RSpec.describe 'long' do
64
+ it 'waits' do
65
+ File.write('ready', '')
66
+ sleep 20
67
+ end
68
+ end
69
+ RUBY
70
+ 'unloadable' => <<~RUBY
71
+ require_relative 'flow_helper'
72
+
73
+ UndefinedConstant.call
74
+ RUBY
75
+ }
76
+
77
+ let(:dir) { File.realpath(Dir.mktmpdir('flow-lifecycle')) }
78
+
79
+ before { FlowProject.build(dir, flows) }
80
+
81
+ after { FileUtils.rm_rf(dir) }
82
+
83
+ # Unsets the variables the test process may carry that would change a run
84
+ def clean_env
85
+ %w[HEADLESS RAIDER_FLOW_NAME RAIDER_FLOW_HEADED RAIDER_FLOW_ATTEMPT RAIDER_FLOW_TRIGGER RAIDER_FLOW_RUN_ID
86
+ RAIDER_SECRET_TOKEN RAIDER_INPUT_WHO FAKE_DRIVER_FAIL FAKE_STEPS].to_h { |key| [key, nil] }
87
+ end
88
+
89
+ def run_flow(name, env = {})
90
+ out, err, status = Open3.capture3(clean_env.merge(env), RbConfig.ruby, '-S', 'rspec', "flows/#{name}_flow.rb", chdir: dir)
91
+ [out + err, status.exitstatus]
92
+ end
93
+
94
+ def log(name)
95
+ file = File.join(dir, ".raider/flows/#{name}/runs.jsonl")
96
+ File.exist?(file) ? File.readlines(file).map { |line| JSON.parse(line) } : []
97
+ end
98
+
99
+ def mode(relative) = File.stat(File.join(dir, relative)).mode & 0o777
100
+
101
+ def write_env_file(content, permissions)
102
+ FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
103
+ File.write(File.join(dir, '.raider/flows/env'), content)
104
+ File.chmod(permissions, File.join(dir, '.raider/flows/env'))
105
+ end
106
+
107
+ it 'records a completed run with its results folder, headless by default', :aggregate_failures do
108
+ output, code = run_flow('ok', 'RAIDER_INPUT_WHO' => 'ada')
109
+ line = log('ok').last
110
+
111
+ expect(code).to eq(0)
112
+ expect(output).to include('headless=true who=ada')
113
+ expect(line).to include('status' => 'completed', 'attempt' => 1, 'trigger' => 'manual')
114
+ expect(line['results_dir']).to match(%r{\Aflow-results/ok/\d{8}T\d{6}Z\z})
115
+ expect(File).to be_directory(File.join(dir, line['results_dir']))
116
+ expect(Time.iso8601(line['finished_at'])).to be >= Time.iso8601(line['started_at'])
117
+ end
118
+
119
+ it 'keeps the run log and lock private' do
120
+ run_flow('ok')
121
+
122
+ expect([mode('.raider/flows'), mode('.raider/flows/ok'), mode('.raider/flows/ok/runs.jsonl'), mode('.raider/flows/ok.lock')])
123
+ .to eq([0o700, 0o700, 0o600, 0o600])
124
+ end
125
+
126
+ it 'opens the browser when RAIDER_FLOW_HEADED=1' do
127
+ output, = run_flow('ok', 'RAIDER_FLOW_HEADED' => '1')
128
+
129
+ expect(output).to include('headless=unset')
130
+ end
131
+
132
+ it 'takes the attempt, trigger and run id from the wrapper' do
133
+ run_flow('ok', 'RAIDER_FLOW_ATTEMPT' => '2', 'RAIDER_FLOW_TRIGGER' => 'schedule', 'RAIDER_FLOW_RUN_ID' => 'abcdef0123456789')
134
+
135
+ expect(log('ok').last).to include('attempt' => 2, 'trigger' => 'schedule', 'run_id' => 'abcdef0123456789')
136
+ end
137
+
138
+ it 'falls back to manual for an unknown trigger and drops a malformed run id' do
139
+ run_flow('ok', 'RAIDER_FLOW_TRIGGER' => 'cron; rm -rf /', 'RAIDER_FLOW_RUN_ID' => '../x')
140
+
141
+ expect(log('ok').last).to include('trigger' => 'manual').and(satisfy { |line| !line.key?('run_id') })
142
+ end
143
+
144
+ it 'records a failed step with its line in the flow file' do
145
+ _output, code = run_flow('broken')
146
+ line = log('broken').last
147
+
148
+ expect(code).to eq(1)
149
+ expect(line).to include('status' => 'failed', 'failed_step' => { 'title' => 'downloads the report', 'line' => 7 })
150
+ end
151
+
152
+ it 'names the recorded step when the report has the desktop step map' do
153
+ run_flow('recorded', 'FAKE_STEPS' => '1')
154
+
155
+ expect(log('recorded').last['failed_step']).to eq('number' => 2, 'title' => 'click "Export"', 'line' => 5)
156
+ end
157
+
158
+ it 'records a setup error when the driver fails before the first step' do
159
+ _output, code = run_flow('ok', 'FAKE_DRIVER_FAIL' => '1')
160
+
161
+ expect(code).to eq(1)
162
+ expect(log('ok').last).to include('status' => 'setup_error').and(satisfy { |line| !line.key?('failed_step') })
163
+ end
164
+
165
+ it 'records a setup error when the flow file does not load' do
166
+ _output, code = run_flow('unloadable')
167
+
168
+ expect(code).not_to eq(0)
169
+ expect(log('unloadable').last['status']).to eq('setup_error')
170
+ end
171
+
172
+ it 'refuses a RAIDER_FLOW_NAME that is not the flow being run' do
173
+ output, code = run_flow('ok', 'RAIDER_FLOW_NAME' => 'other')
174
+
175
+ expect(code).not_to eq(0)
176
+ expect(output).to include('RAIDER_FLOW_NAME is "other" but the flow is "ok"')
177
+ expect(log('ok')).to be_empty
178
+ end
179
+
180
+ it 'reports into the run folder and never cleans raider-results' do
181
+ run_flow('ok')
182
+ reporter = JSON.parse(File.read(File.join(dir, 'reporter.json')))
183
+
184
+ expect(reporter).to eq('output_dir' => File.join(dir, log('ok').last['results_dir']), 'clean_output_dir' => false)
185
+ end
186
+
187
+ it 'keeps a refused flow away from raider-results too' do
188
+ run_flow('ok', 'RAIDER_FLOW_NAME' => 'other')
189
+ reporter = JSON.parse(File.read(File.join(dir, 'reporter.json')))
190
+
191
+ expect(reporter['clean_output_dir']).to be(false)
192
+ expect(reporter['output_dir']).to start_with(File.realpath(Dir.tmpdir)).or start_with(Dir.tmpdir)
193
+ end
194
+
195
+ it 'drops a HEADLESS left in the shell for RAIDER_FLOW_HEADED=1' do
196
+ output, = run_flow('ok', 'RAIDER_FLOW_HEADED' => '1', 'HEADLESS' => 'true')
197
+
198
+ expect(output).to include('headless=unset')
199
+ end
200
+
201
+ it 'exits 3 with one busy line when another run holds the lock, and never waits' do
202
+ FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
203
+ File.open(File.join(dir, '.raider/flows/ok.lock'), File::RDWR | File::CREAT, 0o600) do |held|
204
+ held.flock(File::LOCK_EX)
205
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
+
207
+ output, code = run_flow('ok')
208
+
209
+ expect(code).to eq(3)
210
+ expect(output).to include('ok is already running')
211
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 30
212
+ end
213
+ expect(log('ok').map { |line| line['status'] }).to eq(['busy'])
214
+ expect(log('ok').last['results_dir']).to be_nil
215
+ end
216
+
217
+ it 'ends the step on the first Ctrl-C, ignores the second, quits the driver and records the run' do
218
+ pid = Process.spawn(clean_env, RbConfig.ruby, '-S', 'rspec', 'flows/long_flow.rb', chdir: dir, %i[out err] => File::NULL)
219
+ Timeout.timeout(30) { sleep 0.05 until File.exist?(File.join(dir, 'ready')) }
220
+ 2.times do
221
+ Process.kill('INT', pid)
222
+ sleep 0.2
223
+ end
224
+ Timeout.timeout(15) { Process.wait(pid) }
225
+
226
+ expect(log('long').map { |line| line['status'] }).to eq(['setup_error'])
227
+ expect(File).to exist(File.join(dir, 'driver_quit'))
228
+ end
229
+
230
+ it 'releases the lock when the run ends' do
231
+ run_flow('ok')
232
+ _output, code = run_flow('ok')
233
+
234
+ expect(code).to eq(0)
235
+ expect(log('ok').map { |line| line['status'] }).to eq(%w[completed completed])
236
+ end
237
+
238
+ context 'with the flow env file' do
239
+ it 'loads a 0600 file without printing its values' do
240
+ write_env_file("# secrets\nRAIDER_SECRET_TOKEN=\"hunter2\"\n\nbad line\n", 0o600)
241
+
242
+ output, = run_flow('ok')
243
+
244
+ expect(output).to include('secret loaded=true', '.raider/flows/env line 4 ignored: expected KEY=value')
245
+ expect(output).not_to include('hunter2')
246
+ expect(File.read(File.join(dir, '.raider/flows/ok/runs.jsonl'))).not_to include('hunter2')
247
+ end
248
+
249
+ it 'ignores a 0644 file with a warning' do
250
+ write_env_file("RAIDER_SECRET_TOKEN=hunter2\n", 0o644)
251
+
252
+ output, code = run_flow('ok')
253
+
254
+ expect(code).to eq(0)
255
+ expect(output).to include('ignored .raider/flows/env: permissions must be 600', 'secret loaded=false')
256
+ end
257
+
258
+ it 'ignores a link to a file elsewhere' do
259
+ target = File.join(dir, 'elsewhere')
260
+ File.write(target, "RAIDER_SECRET_TOKEN=hunter2\n")
261
+ File.chmod(0o600, target)
262
+ FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
263
+ File.symlink(target, File.join(dir, '.raider/flows/env'))
264
+
265
+ output, = run_flow('ok')
266
+
267
+ expect(output).to include('ignored .raider/flows/env: it must be a regular file you own', 'secret loaded=false')
268
+ end
269
+
270
+ it 'does not override a variable already set' do
271
+ write_env_file("RAIDER_SECRET_TOKEN=hunter2\n", 0o600)
272
+
273
+ output, = run_flow('ok', 'RAIDER_SECRET_TOKEN' => 'from-shell')
274
+
275
+ expect(output).to include('secret loaded=false')
276
+ end
277
+
278
+ it 'keeps secret values out of the failed step title' do
279
+ write_env_file("RAIDER_SECRET_TOKEN=hunter2\n", 0o600)
280
+
281
+ run_flow('leaky')
282
+
283
+ expect(log('leaky').last['failed_step']['title']).to eq('types [secret] somewhere')
284
+ expect(File.read(File.join(dir, '.raider/flows/leaky/runs.jsonl'))).not_to include('hunter2')
285
+ end
286
+ end
287
+
288
+ context 'when something fails after the lock is taken' do
289
+ it 'still records the run when flow-results is not a folder' do
290
+ File.write(File.join(dir, 'flow-results'), '')
291
+
292
+ _output, code = run_flow('ok')
293
+
294
+ expect(code).not_to eq(0)
295
+ expect(log('ok').last).to include('status' => 'setup_error', 'results_dir' => nil)
296
+ end
297
+
298
+ it 'skips an env line that is not UTF-8 and runs the flow' do
299
+ write_env_file("RAIDER_SECRET_TOKEN=hunter2\nBAD=\xFF\n".b, 0o600)
300
+
301
+ output, code = run_flow('ok')
302
+
303
+ expect(code).to eq(0)
304
+ expect(output).to include('.raider/flows/env line 2 ignored', 'secret loaded=true')
305
+ end
306
+ end
307
+
308
+ it 'refuses a flow named env, whose log folder would be the secrets file' do
309
+ output, code = run_flow('env')
310
+
311
+ expect(code).not_to eq(0)
312
+ expect(output).to include('invalid flow name "env"')
313
+ expect(File).not_to exist(File.join(dir, '.raider/flows/env'))
314
+ end
315
+
316
+ it 'leaves no lock, record or results for rspec --dry-run' do
317
+ Open3.capture3(clean_env, RbConfig.ruby, '-S', 'rspec', '--dry-run', 'flows/ok_flow.rb', chdir: dir)
318
+
319
+ expect([log('ok'), File.exist?(File.join(dir, 'flow-results'))]).to eq([[], false])
320
+ end
321
+
322
+ it 'never lets the env file set RAIDER_FLOW_* and clamps the attempt to 1' do
323
+ write_env_file("RAIDER_FLOW_TRIGGER=schedule\n", 0o600)
324
+
325
+ output, = run_flow('ok', 'RAIDER_FLOW_ATTEMPT' => '0')
326
+
327
+ expect(output).to include('.raider/flows/env line 1 ignored')
328
+ expect(log('ok').last).to include('trigger' => 'manual', 'attempt' => 1)
329
+ end
330
+
331
+ it 'ignores a FIFO env file without waiting for a writer' do
332
+ FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
333
+ File.mkfifo(File.join(dir, '.raider/flows/env'), 0o600)
334
+
335
+ output, code = Timeout.timeout(60) { run_flow('ok') }
336
+
337
+ expect(code).to eq(0)
338
+ expect(output).to include('ignored .raider/flows/env: it must be a regular file you own')
339
+ end
340
+
341
+ it 'redacts the longest secret first' do
342
+ write_env_file("RAIDER_SECRET_A=hunt\nRAIDER_SECRET_TOKEN=hunter2\n", 0o600)
343
+
344
+ run_flow('leaky')
345
+
346
+ expect(log('leaky').last['failed_step']['title']).to eq('types [secret] somewhere')
347
+ end
348
+
349
+ it 'redacts the value in use when the shell already set the key' do
350
+ write_env_file("RAIDER_SECRET_TOKEN=hunter2\n", 0o600)
351
+
352
+ run_flow('leaky', 'RAIDER_SECRET_TOKEN' => 'shell-secret')
353
+
354
+ expect(log('leaky').last['failed_step']['title']).to eq('types [secret] somewhere')
355
+ end
356
+
357
+ it 'never prunes through a linked flow-results folder' do
358
+ elsewhere = File.join(dir, 'elsewhere')
359
+ Array.new(25) { |i| FileUtils.mkdir_p(File.join(elsewhere, format('20200101T0000%02dZ', i))) }
360
+ FileUtils.mkdir_p(File.join(dir, 'flow-results'))
361
+ File.symlink(elsewhere, File.join(dir, 'flow-results/ok'))
362
+
363
+ run_flow('ok')
364
+
365
+ expect(Dir.children(elsewhere).grep(/\A2020/).size).to eq(25)
366
+ end
367
+
368
+ it 'keeps the last 50 log lines and the last 20 results folders, and only stamp-named folders' do
369
+ FileUtils.mkdir_p(File.join(dir, '.raider/flows/ok'))
370
+ File.write(File.join(dir, '.raider/flows/ok/runs.jsonl'), Array.new(60) { |i| "#{JSON.generate('n' => i)}\n" }.join)
371
+ old = Array.new(25) { |i| format('flow-results/ok/20200101T0000%02dZ', i) }
372
+ (old + ['flow-results/ok/keep-me']).each { |folder| FileUtils.mkdir_p(File.join(dir, folder)) }
373
+
374
+ run_flow('ok')
375
+
376
+ lines = log('ok')
377
+ stamped = Dir.children(File.join(dir, 'flow-results/ok')).grep(/\A\d{8}T/)
378
+ expect([lines.size, lines.first['n'], lines.last['status']]).to eq([50, 11, 'completed'])
379
+ expect(stamped.size).to eq(20)
380
+ expect(stamped).to include(File.basename(lines.last['results_dir']))
381
+ expect(File).to be_directory(File.join(dir, 'flow-results/ok/keep-me'))
382
+ end
383
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../../../lib/utilities/flows/next_run'
5
+
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
8
+ let(:dir) { File.realpath(Dir.mktmpdir('flows-next-run')) }
9
+ let(:project) { RubyRaider::Flows::Project.new(dir) }
10
+
11
+ around do |example|
12
+ zone = ENV.fetch('TZ', nil)
13
+ ENV['TZ'] = 'Europe/Copenhagen'
14
+ example.run
15
+ ensure
16
+ ENV['TZ'] = zone
17
+ end
18
+
19
+ after { FileUtils.rm_rf(dir) }
20
+
21
+ def rule(every, at = '08:00', on: nil)
22
+ RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: every, at: at, on: on)
23
+ end
24
+
25
+ def next_after(schedule, *local)
26
+ described_class.after(schedule, Time.local(*local))
27
+ end
28
+
29
+ # [wall clock, UTC offset in hours] of a local time
30
+ def wall(time)
31
+ [time.strftime('%F %R'), time.utc_offset / 3600]
32
+ end
33
+
34
+ describe 'every day' do
35
+ it 'runs later today, or tomorrow once the time has passed' do
36
+ expect([wall(next_after(rule('day'), 2026, 11, 9, 7, 59)), wall(next_after(rule('day'), 2026, 11, 9, 8, 0))]).to eq(
37
+ [['2026-11-09 08:00', 1], ['2026-11-10 08:00', 1]]
38
+ )
39
+ end
40
+
41
+ it 'is strictly after now, by seconds too' do
42
+ expect(wall(next_after(rule('day'), 2026, 11, 9, 8, 0, 1))).to eq(['2026-11-10 08:00', 1])
43
+ end
44
+
45
+ it 'crosses a month and a year' do
46
+ expect(wall(next_after(rule('day'), 2026, 12, 31, 9, 0))).to eq(['2027-01-01 08:00', 1])
47
+ end
48
+ end
49
+
50
+ describe 'every weekday' do
51
+ it 'skips the weekend' do
52
+ # 2026-11-13 is a Friday
53
+ expect(wall(next_after(rule('weekday'), 2026, 11, 13, 9, 0))).to eq(['2026-11-16 08:00', 1])
54
+ end
55
+ end
56
+
57
+ describe 'every week' do
58
+ it 'runs on its day, a week later once that has passed' do
59
+ # 2026-11-09 is a Monday
60
+ expect([wall(next_after(rule('week', on: 'wed'), 2026, 11, 9, 9, 0)), wall(next_after(rule('week', on: 'mon'), 2026, 11, 9, 8, 0))]).to eq(
61
+ [['2026-11-11 08:00', 1], ['2026-11-16 08:00', 1]]
62
+ )
63
+ end
64
+ end
65
+
66
+ describe 'every hour' do
67
+ it 'runs at the next matching minute' do
68
+ expect([wall(next_after(rule('hour', ':15'), 2026, 11, 9, 10, 20)), wall(next_after(rule('hour', ':15'), 2026, 11, 9, 10, 14, 59))]).to eq(
69
+ [['2026-11-09 11:15', 1], ['2026-11-09 10:15', 1]]
70
+ )
71
+ end
72
+
73
+ it 'crosses midnight' do
74
+ expect(wall(next_after(rule('hour', ':00'), 2026, 11, 9, 23, 30))).to eq(['2026-11-10 00:00', 1])
75
+ end
76
+ end
77
+
78
+ # Copenhagen: 2026-03-29 02:00 CET jumps to 03:00 CEST; 2026-10-25 03:00 CEST falls back to 02:00 CET
79
+ describe 'the last Sunday of March' do
80
+ it 'runs a time in the skipped hour at the first minute after the gap' do
81
+ expect(wall(next_after(rule('day', '02:30'), 2026, 3, 29, 0, 0))).to eq(['2026-03-29 03:00', 2])
82
+ end
83
+
84
+ it 'goes back to the normal time the next day' do
85
+ expect(wall(next_after(rule('day', '02:30'), 2026, 3, 29, 3, 0))).to eq(['2026-03-30 02:30', 2])
86
+ end
87
+
88
+ it 'keeps a time outside the gap' do
89
+ expect(wall(next_after(rule('day'), 2026, 3, 29, 0, 0))).to eq(['2026-03-29 08:00', 2])
90
+ end
91
+
92
+ it 'runs every hour at the next real matching minute' do
93
+ expect(wall(next_after(rule('hour', ':30'), 2026, 3, 29, 1, 45))).to eq(['2026-03-29 03:30', 2])
94
+ end
95
+ end
96
+
97
+ describe 'the last Sunday of October' do
98
+ it 'runs a repeated time at its first occurrence' do
99
+ expect(wall(next_after(rule('day', '02:30'), 2026, 10, 25, 0, 0))).to eq(['2026-10-25 02:30', 2])
100
+ end
101
+
102
+ it 'does not run again at the second occurrence' do
103
+ first = next_after(rule('day', '02:30'), 2026, 10, 25, 0, 0)
104
+
105
+ expect(wall(described_class.after(rule('day', '02:30'), first))).to eq(['2026-10-26 02:30', 1])
106
+ end
107
+
108
+ it 'runs every hour in both repeated hours' do
109
+ first = next_after(rule('hour', ':30'), 2026, 10, 25, 1, 45)
110
+ second = described_class.after(rule('hour', ':30'), first)
111
+
112
+ expect([wall(first), wall(second), second - first]).to eq([['2026-10-25 02:30', 2], ['2026-10-25 02:30', 1], 3600.0])
113
+ end
114
+
115
+ it 'runs a week rule on the changeover day' do
116
+ expect(wall(next_after(rule('week', '02:30', on: 'sun'), 2026, 10, 20, 12, 0))).to eq(['2026-10-25 02:30', 2])
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require_relative '../../../lib/utilities/flows/notifier'
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
8
+ # Records each command instead of running it: no real osascript, notify-send or PowerShell
9
+ let(:runner) do
10
+ Class.new do
11
+ attr_reader :calls
12
+ attr_accessor :exitstatus
13
+
14
+ def initialize
15
+ @calls = []
16
+ @exitstatus = 0
17
+ end
18
+
19
+ def call(*argv, env: {}, timeout: nil, **)
20
+ @calls << { argv: argv, env: env, timeout: timeout }
21
+ RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: '', stderr: 'no notification server', exitstatus: exitstatus)
22
+ end
23
+ end.new
24
+ end
25
+
26
+ let(:err) { StringIO.new }
27
+ let(:hostile) do
28
+ ["'; rm -rf ~; '", '" & do shell script "id" & "', '$(id)', '</text>', '-u critical', '$env:USERPROFILE', '`whoami`']
29
+ end
30
+
31
+ let(:powershell) { 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' }
32
+
33
+ def notifier(host_os) = described_class.new(runner: runner, host_os: host_os, err: err, powershell: powershell)
34
+
35
+ it 'passes the title and message to osascript as argv items of a fixed script on macOS' do
36
+ expect(notifier('darwin25').notify('Ruby Raider flow report', 'Failed at step 3 (Open report)')).to be(true)
37
+
38
+ expect(runner.calls).to eq([{ argv: ['/usr/bin/osascript', '-e', 'on run argv', '-e',
39
+ 'display notification (item 2 of argv) with title (item 1 of argv)', '-e', 'end run', '--',
40
+ 'Ruby Raider flow report', 'Failed at step 3 (Open report)'], env: {}, timeout: 15 }])
41
+ end
42
+
43
+ it 'ends osascript option parsing before the title, so -e in a title is not a script' do
44
+ notifier('darwin25').notify('-e', 'property p : (1 + 2)')
45
+
46
+ expect(runner.calls.first[:argv].last(3)).to eq(['--', '-e', 'property p : (1 + 2)'])
47
+ end
48
+
49
+ it 'keeps hostile text out of the script on every OS' do
50
+ hosts = %w[darwin25 linux-gnu mingw32]
51
+ scripts = hosts.to_h do |host|
52
+ hostile.each { |text| notifier(host).notify(text, text) }
53
+ calls = runner.calls.pop(hostile.size)
54
+ [host, calls.map { |call| host == 'mingw32' ? call[:argv] : call[:argv][0...-2] }.uniq]
55
+ end
56
+
57
+ expect(scripts.values.map(&:size)).to eq([1, 1, 1])
58
+ expect(scripts.values.flatten.join("\n")).not_to include(*hostile)
59
+ end
60
+
61
+ it 'passes hostile text unchanged as the last argv items on macOS' do
62
+ hostile.each { |text| notifier('darwin25').notify(text, text) }
63
+
64
+ expect(runner.calls.map { |call| call[:argv].last(2) }).to eq(hostile.map { |text| [text, text] })
65
+ end
66
+
67
+ it 'stops notify-send option parsing before the title and escapes markup in the message on Linux' do
68
+ notifier('linux-gnu').notify('-u critical', 'a < b & c > d')
69
+
70
+ expect(runner.calls.first[:argv]).to eq(['notify-send', '--app-name=Ruby Raider', '--', '-u critical', 'a &lt; b &amp; c &gt; d'])
71
+ end
72
+
73
+ it 'passes the title and message to a fixed PowerShell script through the environment on Windows' do
74
+ notifier('mswin64').notify('$env:USERPROFILE', '`whoami`')
75
+
76
+ expect(runner.calls.first).to eq(
77
+ argv: [powershell, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', described_class::TOAST_SCRIPT],
78
+ env: { 'RAIDER_NOTIFY_TITLE' => '$env:USERPROFILE', 'RAIDER_NOTIFY_MESSAGE' => '`whoami`' }, timeout: 15
79
+ )
80
+ end
81
+
82
+ it 'finds PowerShell under SystemRoot by default' do
83
+ allow(ENV).to receive(:fetch).and_call_original
84
+ allow(ENV).to receive(:fetch).with('SystemRoot', 'C:\\Windows').and_return('D:\\Win')
85
+ described_class.new(runner: runner, host_os: 'mingw32', err: err).notify('t', 'm')
86
+
87
+ expect(runner.calls.first[:argv].first).to eq(File.join('D:\\Win', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
88
+ end
89
+
90
+ it 'builds the toast from the environment, never from text joined into XML' do
91
+ script = described_class::TOAST_SCRIPT
92
+
93
+ expect(script).to include('$env:RAIDER_NOTIFY_TITLE', '$env:RAIDER_NOTIFY_MESSAGE', 'CreateTextNode', 'ToastText02')
94
+ expect(script).not_to include('"', "\n", 'EncodedCommand')
95
+ end
96
+
97
+ it 'makes control characters printable and caps the message at 200 characters' do
98
+ notifier('darwin25').notify("flow\e[2J", "#{'x' * 250}\n")
99
+
100
+ expect(runner.calls.first[:argv].last(2)).to eq(['flow?[2J', 'x' * 200])
101
+ end
102
+
103
+ it 'does nothing on another OS' do
104
+ expect([notifier('solaris2').notify('t', 'm'), runner.calls]).to eq([false, []])
105
+ end
106
+
107
+ it 'returns false and warns once when the notification fails, without raising' do
108
+ runner.exitstatus = 1
109
+ sender = notifier('linux-gnu')
110
+
111
+ expect([sender.notify('t', 'm'), sender.notify('t', 'm')]).to eq([false, false])
112
+ expect(err.string).to eq("Could not show a desktop notification: no notification server\n")
113
+ end
114
+
115
+ it 'returns false when the runner raises' do
116
+ allow(runner).to receive(:call).and_raise(IOError, 'closed stream')
117
+
118
+ expect(notifier('darwin25').notify('t', 'm')).to be(false)
119
+ expect(err.string).to include('closed stream')
120
+ end
121
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require_relative '../../../../lib/utilities/flows/os/command_runner'
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
8
+ let(:runner) { described_class.new }
9
+ let(:ruby) { RbConfig.ruby }
10
+
11
+ it 'captures stdout, stderr and the exit status' do
12
+ result = runner.call(ruby, '-e', '$stdout.print "out"; $stderr.print "err"; exit 3')
13
+
14
+ expect([result.stdout, result.stderr, result.exitstatus, result.success?]).to eq(['out', 'err', 3, false])
15
+ end
16
+
17
+ it 'succeeds on exit 0' do
18
+ expect(runner.call(ruby, '-e', 'exit 0').success?).to be(true)
19
+ end
20
+
21
+ it 'feeds stdin' do
22
+ expect(runner.call(ruby, '-e', 'print $stdin.read.upcase', stdin_data: "a b\n").stdout).to eq("A B\n")
23
+ end
24
+
25
+ it 'adds env values to the command environment' do
26
+ result = runner.call(ruby, '-e', 'print ENV["RAIDER_NOTIFY_TITLE"]', env: { 'RAIDER_NOTIFY_TITLE' => '$(id)' })
27
+
28
+ expect(result.stdout).to eq('$(id)')
29
+ end
30
+
31
+ it 'stops a command that runs past the timeout' do
32
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ result = runner.call(ruby, '-e', 'sleep 10', timeout: 0.3)
34
+
35
+ expect([result.success?, result.stderr]).to eq([false, "#{ruby} took longer than 0.3 s"])
36
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 5
37
+ end
38
+
39
+ it 'passes arguments without a shell' do
40
+ hostile = ["'; rm -rf ~; '", '$(id)', '%h', '</string>']
41
+
42
+ expect(runner.call(ruby, '-e', 'print ARGV.join("|")', *hostile).stdout).to eq(hostile.join('|'))
43
+ end
44
+
45
+ it 'never runs a single string through a shell' do
46
+ result = runner.call('echo $(id)')
47
+
48
+ expect([result.success?, result.stdout]).to eq([false, ''])
49
+ end
50
+
51
+ it 'returns a failed result for a missing executable' do
52
+ result = runner.call('/no/such/raider-command', 'x')
53
+
54
+ expect([result.success?, result.exitstatus, result.stderr]).to match([false, 127, /raider-command/])
55
+ end
56
+ end