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,505 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'rexml/document'
5
+ require_relative '../../../../lib/utilities/flows/os/task_scheduler'
6
+
7
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
8
+ # The fake runner, fixed clock and project folders sit next to the adapter under test
9
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
10
+ RSpec.describe RubyRaider::Flows::Os::TaskScheduler do # rubocop:disable RSpec/FilePath
11
+ # Records every command and answers from a table keyed by the schtasks verb; never runs anything
12
+ let(:fake_runner_class) do
13
+ Class.new do
14
+ attr_reader :calls, :answers
15
+
16
+ def initialize
17
+ @calls = []
18
+ @answers = {}
19
+ end
20
+
21
+ def call(*argv, stdin_data: nil)
22
+ @calls << argv
23
+ @seen&.call(argv, stdin_data)
24
+ stderr, status, stdout = answers.fetch(argv[1], ['', 0])
25
+ RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: stdout.to_s, stderr: stderr, exitstatus: status)
26
+ end
27
+
28
+ def on_call(&block) = @seen = block
29
+ end
30
+ end
31
+
32
+ let(:hostile) { %q('; rm -rf ~; ' </string> %h $(id) " \\ &) }
33
+ let(:tmp) { File.realpath(Dir.mktmpdir('flows-schtasks')) }
34
+ let(:project_dir) { File.join(tmp, 'plain') }
35
+ let(:runner) { fake_runner_class.new }
36
+ let(:clock) { Struct.new(:now).new(Time.new(2026, 11, 9, 10, 30, 0)) }
37
+ let(:schtasks) { 'C:\\Windows\\System32\\schtasks.exe' }
38
+ let(:scheduler) { described_class.new(runner: runner, clock: clock, schtasks: schtasks) }
39
+ let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
40
+
41
+ def schedule(dir: project_dir, **options)
42
+ FileUtils.mkdir_p(dir)
43
+ project = RubyRaider::Flows::Project.new(File.realpath(dir))
44
+ RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
45
+ end
46
+
47
+ def task_name(item) = "\\RubyRaider\\#{item.os_id}"
48
+
49
+ # schtasks /Query /FO CSV /NH: one row per task, full name first
50
+ def csv_rows(*names) = names.map { |name| %("#{name}","11/10/2026 8:00:00 AM","Ready"\r\n) }.join
51
+
52
+ def list_call = [schtasks, '/Query', '/FO', 'CSV', '/NH']
53
+
54
+ # CommandLineToArgvW / MSVCRT rules for the arguments after the program name: 2n backslashes and a
55
+ # quote give n backslashes and toggle quoting, 2n+1 give n and a literal quote, other backslashes are
56
+ # literal, unquoted space or tab splits. The quoter never writes "" inside quotes, so the post-2008
57
+ # MSVCRT "" rule never applies.
58
+ def split_arguments(line)
59
+ args = []
60
+ current = nil
61
+ quoted = false
62
+ index = 0
63
+ while index < line.length
64
+ char = line[index]
65
+ if char == '\\'
66
+ run = line[index..][/\A\\+/].length
67
+ index += run
68
+ if line[index] == '"'
69
+ current = "#{current}#{'\\' * (run / 2)}"
70
+ if run.odd?
71
+ current += '"'
72
+ index += 1
73
+ end
74
+ else
75
+ current = "#{current}#{'\\' * run}"
76
+ end
77
+ next
78
+ elsif char == '"'
79
+ quoted = !quoted
80
+ current ||= +''
81
+ elsif [' ', "\t"].include?(char) && !quoted
82
+ args << current if current
83
+ current = nil
84
+ else
85
+ current = "#{current}#{char}"
86
+ end
87
+ index += 1
88
+ end
89
+ args << current if current
90
+ args
91
+ end
92
+
93
+ # ruby.exe's own reading: a port of w32_cmdvector in ruby/win32/win32.c. Pass 1 splits on unquoted
94
+ # space, tab or newline (' and " both quote; a backslash counts toward escaping a quote except inside
95
+ # '...'), and marks a * ? [ { outside '...' (even inside "...") for globbing. Pass 2 strips the quotes:
96
+ # an even run of backslashes before a quote of the other kind is left as it is, every other quote eats
97
+ # (run + 1) / 2 backslashes. A globbed element is refused here: the quoter must never produce one.
98
+ def ruby_split_arguments(line)
99
+ elements = []
100
+ index = 0
101
+ loop do
102
+ index += 1 while line[index]&.match?(/[ \t\n\v\f\r]/)
103
+ break if index >= line.length
104
+
105
+ element, index, glob = ruby_element(line, index)
106
+ raise "ruby.exe would glob #{element.inspect} in #{line.inspect}" if glob
107
+
108
+ elements << element
109
+ end
110
+ elements
111
+ end
112
+
113
+ def ruby_element(line, index)
114
+ base = index
115
+ quote = nil
116
+ slashes = 0
117
+ glob = false
118
+ done = false
119
+ while !done && index < line.length
120
+ char = line[index]
121
+ case char
122
+ when '\\' then slashes += 1 unless quote == "'"
123
+ when ' ', "\t", "\n"
124
+ done = true unless quote
125
+ slashes = 0
126
+ when '*', '?', '[', '{'
127
+ glob = true unless quote == "'"
128
+ slashes = 0
129
+ when "'", '"'
130
+ if slashes.even?
131
+ if quote.nil? then quote = char
132
+ elsif quote == char
133
+ index += 1 if char == '"' && line[index + 1] == '"'
134
+ quote = nil
135
+ end
136
+ end
137
+ slashes = 0
138
+ else slashes = 0
139
+ end
140
+ index += 1
141
+ end
142
+ length = index - base - (done ? 1 : 0)
143
+ [ruby_unquote(line[base, length]), index, glob]
144
+ end
145
+
146
+ def ruby_unquote(text)
147
+ chars = text.chars
148
+ at = 0
149
+ slashes = 0
150
+ quote = nil
151
+ while at < chars.length
152
+ char = chars[at]
153
+ if char == '\\'
154
+ at += 1
155
+ slashes += 1 unless quote == "'"
156
+ next
157
+ end
158
+ unless ["'", '"'].include?(char)
159
+ at += 1
160
+ slashes = 0
161
+ next
162
+ end
163
+ if slashes.even? && quote && quote != char
164
+ at += 1
165
+ slashes = 0
166
+ next
167
+ end
168
+ eaten = (slashes + 1) / 2
169
+ chars.slice!(at - eaten, eaten + (slashes.even? ? 1 : 0))
170
+ at -= eaten
171
+ if slashes.odd?
172
+ at += 1
173
+ elsif quote
174
+ at += 1 if quote == '"' && chars[at] == '"'
175
+ quote = nil
176
+ else
177
+ quote = char
178
+ end
179
+ slashes = 0
180
+ end
181
+ chars.join
182
+ end
183
+
184
+ def parsed(text) = REXML::Document.new(text)
185
+
186
+ def field(text, path) = parsed(text).elements[path]&.text.to_s
187
+
188
+ # What a daily 08:00 schedule must produce, byte for byte
189
+ def daily_xml(item)
190
+ args = described_class.join(item.argv.drop(1))
191
+
192
+ <<~XML
193
+ <?xml version="1.0" encoding="UTF-16"?>
194
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
195
+ <RegistrationInfo>
196
+ <Description>Ruby Raider flow report (every day at 08:00)</Description>
197
+ <URI>\\RubyRaider\\#{item.os_id}</URI>
198
+ </RegistrationInfo>
199
+ <Triggers>
200
+ <CalendarTrigger>
201
+ <StartBoundary>2026-11-10T08:00:00</StartBoundary>
202
+ <Enabled>true</Enabled>
203
+ <ScheduleByDay>
204
+ <DaysInterval>1</DaysInterval>
205
+ </ScheduleByDay>
206
+ </CalendarTrigger>
207
+ </Triggers>
208
+ <Principals>
209
+ <Principal id="Author">
210
+ <LogonType>InteractiveToken</LogonType>
211
+ <RunLevel>LeastPrivilege</RunLevel>
212
+ </Principal>
213
+ </Principals>
214
+ <Settings>
215
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
216
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
217
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
218
+ <StartWhenAvailable>true</StartWhenAvailable>
219
+ <Enabled>true</Enabled>
220
+ </Settings>
221
+ <Actions Context="Author">
222
+ <Exec>
223
+ <Command>#{item.ruby}</Command>
224
+ <Arguments>#{args}</Arguments>
225
+ <WorkingDirectory>#{item.working_directory}</WorkingDirectory>
226
+ </Exec>
227
+ </Actions>
228
+ </Task>
229
+ XML
230
+ end
231
+
232
+ after { FileUtils.rm_rf(tmp) }
233
+
234
+ it 'is called Task Scheduler in messages' do
235
+ expect(scheduler.label).to eq('Task Scheduler')
236
+ end
237
+
238
+ it 'names the task under the RubyRaider folder' do
239
+ item = schedule
240
+ expect(scheduler.task_name(item.os_id)).to eq(task_name(item))
241
+ end
242
+
243
+ describe '.quote' do
244
+ {
245
+ 'plain' => 'plain',
246
+ '' => '""',
247
+ 'with space' => '"with space"',
248
+ "tab\there" => "\"tab\there\"",
249
+ 'C:\\path with space\\' => '"C:\\path with space\\\\"',
250
+ 'C:\\no\\space\\' => 'C:\\no\\space\\',
251
+ 'a\\"b' => '"a\\\\\\"b"',
252
+ 'say "hi"' => '"say \\"hi\\""',
253
+ 'a\\\\b c' => '"a\\\\b c"',
254
+ '%h&$(id)' => '%h&$(id)',
255
+ "O'Brien" => %("O'Brien"),
256
+ 'a*b' => "'a*b'",
257
+ 'p[1]' => "'p[1]'",
258
+ 'q?' => "'q?'",
259
+ '{x}' => "'{x}'",
260
+ 'x{1,2}' => "'x{1,2}'",
261
+ 'C:\\a b\\*.rb' => "'C:\\a b\\*.rb'",
262
+ 'a*"b' => %('a*"b'),
263
+ "a\\'b c" => %("a\\\\\\'b c"),
264
+ "a\\\\'b c" => %("a\\\\'b c")
265
+ }.each do |arg, quoted|
266
+ it "quotes #{arg.inspect} as #{quoted.inspect}" do
267
+ expect(described_class.quote(arg)).to eq(quoted)
268
+ end
269
+ end
270
+
271
+ # ruby.exe is the real reader; CommandLineToArgvW agrees except on '...' and on backslashes before '
272
+ it 'gives back every argument when the joined line is read by ruby.exe, and by CommandLineToArgvW where they agree' do
273
+ plain = ['', 'C:\\path with space\\', 'a\\"b', '\\\\', '"', 'x\\', "'; rm -rf ~; '", '</string>', '%h', '$(id)', '\\', '&', hostile,
274
+ "C:\\Users\\O'Brien\\proj", "a=x'", "b=y'", "it's \\\"x\\\" \\"]
275
+ ruby_only = ['p[1]', 'a*b', 'q?', '{x}', 'x{1,2}', 'C:\\a b\\*.rb', 'a*"b\\', "a\\'b c", "a\\\\'b c", "a\\\\\\'b c", "C:\\x\\'q' y"]
276
+
277
+ expect(ruby_split_arguments(described_class.join(plain + ruby_only))).to eq(plain + ruby_only)
278
+ expect(split_arguments(described_class.join(plain))).to eq(plain)
279
+ end
280
+
281
+ it "refuses an argument with both ' and a glob character" do
282
+ expect { described_class.quote("a'*") }.to raise_error(RubyRaider::Flows::Error, /can not pass "a'\*" to ruby\.exe/)
283
+ end
284
+ end
285
+
286
+ describe '#task_xml' do
287
+ it 'writes the exact task for a daily schedule' do
288
+ item = schedule
289
+ expect(scheduler.task_xml(item)).to eq(daily_xml(item))
290
+ end
291
+
292
+ it 'carries no environment and no log file' do
293
+ text = scheduler.task_xml(schedule)
294
+
295
+ expect(text).not_to include('scheduler.log', 'Environment', 'GEM_HOME', 'cmd', 'powershell')
296
+ end
297
+
298
+ it 'runs ruby with the bundle path and the flow run arguments' do
299
+ item = schedule(retries: 2, inputs: %w[env=staging])
300
+ text = scheduler.task_xml(item)
301
+
302
+ expect(field(text, 'Task/Actions/Exec/Command')).to eq(item.ruby)
303
+ expect(split_arguments(field(text, 'Task/Actions/Exec/Arguments'))).to eq(item.argv.drop(1))
304
+ expect(field(text, 'Task/Actions/Exec/WorkingDirectory')).to eq(item.working_directory)
305
+ end
306
+
307
+ it 'starts at the next run: an 11:00 rule at 10:30 starts today at 11:00' do
308
+ trigger = parsed(scheduler.task_xml(schedule(every: 'day', at: '11:00'))).elements['Task/Triggers/CalendarTrigger']
309
+
310
+ expect(trigger.elements['StartBoundary'].text).to eq('2026-11-09T11:00:00')
311
+ end
312
+
313
+ it 'runs every hour from its next run, repeating hourly for a day' do
314
+ trigger = parsed(scheduler.task_xml(schedule(every: 'hour', at: ':15'))).elements['Task/Triggers/CalendarTrigger']
315
+
316
+ expect(trigger.elements.map(&:name)).to eq(%w[Repetition StartBoundary Enabled ScheduleByDay])
317
+ expect(%w[Repetition/Interval Repetition/Duration StartBoundary ScheduleByDay/DaysInterval].map { |path| trigger.elements[path].text })
318
+ .to eq(%w[PT1H P1D 2026-11-09T11:15:00 1])
319
+ end
320
+
321
+ it 'runs every weekday Monday to Friday, weekly' do
322
+ trigger = parsed(scheduler.task_xml(schedule(every: 'weekday', at: '18:05'))).elements['Task/Triggers/CalendarTrigger']
323
+
324
+ expect(trigger.elements['StartBoundary'].text).to eq('2026-11-09T18:05:00')
325
+ expect(trigger.elements['ScheduleByWeek'].elements.map(&:name)).to eq(%w[DaysOfWeek WeeksInterval])
326
+ expect(trigger.elements['ScheduleByWeek/DaysOfWeek'].elements.map(&:name)).to eq(%w[Monday Tuesday Wednesday Thursday Friday])
327
+ expect(trigger.elements['ScheduleByWeek/WeeksInterval'].text).to eq('1')
328
+ end
329
+
330
+ it 'runs every week on its one day' do
331
+ days = %w[mon sat sun].map do |on|
332
+ trigger = parsed(scheduler.task_xml(schedule(every: 'week', on: on))).elements['Task/Triggers/CalendarTrigger']
333
+ [trigger.elements['ScheduleByWeek/DaysOfWeek'].elements.map(&:name), trigger.elements['StartBoundary'].text]
334
+ end
335
+
336
+ expect(days).to eq([[%w[Monday], '2026-11-16T08:00:00'], [%w[Saturday], '2026-11-14T08:00:00'], [%w[Sunday], '2026-11-15T08:00:00']])
337
+ end
338
+
339
+ # One at a time: a lone % in both would read as %...% across the joined Arguments and be refused
340
+ {
341
+ 'an input value' => ->(spec) { spec.schedule(inputs: ["note=#{spec.hostile}"]) },
342
+ 'the project path' => ->(spec) { spec.schedule(dir: File.join(spec.tmp, 'p<', "string> #{spec.hostile}")) }
343
+ }.each do |where, build|
344
+ describe "with hostile values in #{where}" do
345
+ let(:item) { build.call(self) }
346
+ let(:text) { scheduler.task_xml(item) }
347
+
348
+ it 'round-trips them through CRT quoting and XML escaping' do
349
+ line = field(text, 'Task/Actions/Exec/Arguments')
350
+
351
+ expect([split_arguments(line), ruby_split_arguments(line)]).to eq([item.argv.drop(1), item.argv.drop(1)])
352
+ expect(field(text, 'Task/Actions/Exec/WorkingDirectory')).to eq(item.working_directory)
353
+ expect(item.argv.join).to include('</string>', "'; rm -rf ~; '", '$(id)', '%h', '"', '\\', '&')
354
+ end
355
+
356
+ it 'writes them only in escaped form' do
357
+ expect(text).not_to include('</string>', "'; rm", '" \\')
358
+ expect(text.scan(/&(?!amp;|lt;|gt;|quot;|apos;)/)).to be_empty
359
+ expect(text).to include('&lt;/string&gt;', '&apos;; rm -rf ~; &apos;', '\\&quot; \\ &amp;', '$(id)', '%h')
360
+ end
361
+ end
362
+ end
363
+ end
364
+
365
+ describe 'with quotes and glob characters but no spaces' do
366
+ let(:item) { schedule(dir: File.join(tmp, 'Users', "O'Brien", 'proj'), inputs: ["a=x'", "b=y'", 'c=p[1]', 'd=a*b']) }
367
+
368
+ it "quotes them so ruby.exe reads the exact argv back and globs nothing ('...' around a glob)" do
369
+ line = field(scheduler.task_xml(item), 'Task/Actions/Exec/Arguments')
370
+
371
+ expect(ruby_split_arguments(line)).to eq(item.argv.drop(1))
372
+ expect(line).to include(%("#{item.project_root}"), %("a=x'"), %("b=y'"), "'c=p[1]'", "'d=a*b'")
373
+ end
374
+
375
+ it "refuses an input with both ' and a glob character before any schtasks call" do
376
+ expect { scheduler.install(schedule(inputs: ["e=a'*"])) }.to raise_error(RubyRaider::Flows::Error, /ruby\.exe/)
377
+ expect(runner.calls).to be_empty
378
+ end
379
+ end
380
+
381
+ describe 'with %NAME% that Task Scheduler would expand' do
382
+ it 'refuses it in an input value before any schtasks call' do
383
+ item = schedule(inputs: ['note=%PATH%'])
384
+
385
+ expect { scheduler.install(item) }.to raise_error(RubyRaider::Flows::Error, /Task Scheduler would expand %PATH% as an environment variable/)
386
+ expect(runner.calls).to be_empty
387
+ end
388
+
389
+ it 'refuses it in the project path before any schtasks call' do
390
+ item = schedule(dir: File.join(tmp, '%PATH%', 'proj'))
391
+
392
+ expect { scheduler.install(item) }.to raise_error(RubyRaider::Flows::Error, /would expand %PATH%/)
393
+ expect { scheduler.task_xml(item) }.to raise_error(RubyRaider::Flows::Error, /would expand %PATH%/)
394
+ expect(runner.calls).to be_empty
395
+ end
396
+
397
+ it 'refuses a %...% formed across two arguments in the joined Arguments' do
398
+ expect { scheduler.install(schedule(inputs: ['a=%x', 'b=y%'])) }.to raise_error(RubyRaider::Flows::Error, /would expand %x b=y%/)
399
+ expect(runner.calls).to be_empty
400
+ end
401
+
402
+ it 'keeps a lone %' do
403
+ item = schedule(inputs: ['note=100% sure'])
404
+
405
+ expect(split_arguments(field(scheduler.task_xml(item), 'Task/Actions/Exec/Arguments')).last).to eq('note=100% sure')
406
+ end
407
+ end
408
+
409
+ describe '#install' do
410
+ it 'writes the task as UTF-16LE with a BOM, creates it with schtasks and deletes the file' do
411
+ item = schedule
412
+ written = nil
413
+ path = nil
414
+ runner.on_call { |argv, _| written = File.binread(path = argv[5]) if argv[1] == '/Create' }
415
+
416
+ expect(scheduler.install(item)).to be(true)
417
+ expect(runner.calls).to eq([[schtasks, '/Create', '/TN', task_name(item), '/XML', path, '/F']])
418
+ expect(written.byteslice(0, 2).bytes).to eq([0xFF, 0xFE])
419
+ expect(written.byteslice(2..).force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8)).to eq(scheduler.task_xml(item))
420
+ expect(File.exist?(path)).to be(false)
421
+ end
422
+
423
+ it 'raises with the scheduler error and still deletes the file when schtasks fails' do
424
+ item = schedule
425
+ path = nil
426
+ runner.on_call { |argv, _| path = argv[5] }
427
+ runner.answers['/Create'] = ["ERROR: Access is denied.\e[2J\n", 1]
428
+
429
+ expect { scheduler.install(item) }.to raise_error(RubyRaider::Flows::Error, /Task Scheduler.*#{Regexp.escape(item.os_id)}.*Access is denied\.\?\[2J/)
430
+ expect(File.exist?(path)).to be(false)
431
+ end
432
+ end
433
+
434
+ describe '#remove' do
435
+ it 'deletes the task' do
436
+ item = schedule
437
+
438
+ expect(scheduler.remove(item.os_id)).to be(true)
439
+ expect(runner.calls).to eq([[schtasks, '/Delete', '/TN', task_name(item), '/F']])
440
+ end
441
+
442
+ it 'is false when the delete fails and the task list does not show it' do
443
+ item = schedule
444
+ runner.answers['/Delete'] = ['ERROR: The system cannot find the file specified.', 1]
445
+ runner.answers['/Query'] = ['', 0, csv_rows('\\Other', "#{task_name(item)}_old")]
446
+
447
+ expect(scheduler.remove(item.os_id)).to be(false)
448
+ expect(runner.calls).to eq([[schtasks, '/Delete', '/TN', task_name(item), '/F'], list_call])
449
+ end
450
+
451
+ it 'raises with the delete error when the task list still shows it' do
452
+ item = schedule
453
+ runner.answers['/Delete'] = ["ERROR: Access\e is denied.\n", 1]
454
+ runner.answers['/Query'] = ['', 0, csv_rows('\\Other', task_name(item))]
455
+
456
+ expect { scheduler.remove(item.os_id) }.to raise_error(RubyRaider::Flows::Error, /Task Scheduler could not delete .*Access\? is denied\.\z/)
457
+ end
458
+
459
+ it 'raises with the delete error when the task list can not be read' do
460
+ runner.answers['/Delete'] = ['ERROR: Access is denied.', 1]
461
+ runner.answers['/Query'] = ['ERROR: list failed', 1]
462
+
463
+ expect { scheduler.remove(schedule.os_id) }.to raise_error(RubyRaider::Flows::Error, /could not delete .*Access is denied/)
464
+ end
465
+
466
+ it 'refuses an os_id that is not a flow schedule id' do
467
+ expect { scheduler.remove('..\\..\\evil') }.to raise_error(RubyRaider::Flows::Error, /Invalid os_id/)
468
+ expect(runner.calls).to be_empty
469
+ end
470
+ end
471
+
472
+ describe '#present?' do
473
+ it 'is true when the task list shows the exact task name' do
474
+ item = schedule
475
+ runner.answers['/Query'] = ['', 0, csv_rows('\\Other', task_name(item))]
476
+
477
+ expect(scheduler.present?(item.os_id)).to be(true)
478
+ expect(runner.calls).to eq([list_call])
479
+ end
480
+
481
+ it 'is false when it shows only other tasks' do
482
+ item = schedule
483
+ runner.answers['/Query'] = ['', 0, csv_rows("#{task_name(item)}_old", '\\Other')]
484
+
485
+ expect(scheduler.present?(item.os_id)).to be(false)
486
+ end
487
+
488
+ it 'raises when the task list can not be read' do
489
+ runner.answers['/Query'] = ["ERROR: no\e list", 1]
490
+
491
+ expect { scheduler.present?(schedule.os_id) }.to raise_error(RubyRaider::Flows::Error, /could not list tasks: ERROR: no\? list/)
492
+ end
493
+ end
494
+
495
+ it 'runs schtasks.exe from SystemRoot by default' do
496
+ allow(ENV).to receive(:fetch).and_call_original
497
+ allow(ENV).to receive(:fetch).with('SystemRoot', anything).and_return('D:\\Win')
498
+ item = schedule
499
+
500
+ described_class.new(runner: runner, clock: clock).remove(item.os_id)
501
+
502
+ expect(runner.calls.first.first).to eq(File.join('D:\\Win', 'System32', 'schtasks.exe'))
503
+ end
504
+ end
505
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'tmpdir'
5
+ require_relative '../../../lib/utilities/flows/project'
6
+
7
+ # Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
8
+ RSpec.describe RubyRaider::Flows::Project do # rubocop:disable RSpec/FilePath
9
+ let(:dir) { Dir.mktmpdir('flows-project') }
10
+ let(:out) { StringIO.new }
11
+ let(:err) { StringIO.new }
12
+ let(:project) { described_class.new(dir) }
13
+
14
+ def write(relative, content)
15
+ file = File.join(dir, relative)
16
+ FileUtils.mkdir_p(File.dirname(file))
17
+ File.write(file, content)
18
+ end
19
+
20
+ def read(relative) = File.read(File.join(dir, relative))
21
+
22
+ def selenium_project(pin: "'~> 4.0'")
23
+ write('Gemfile', "gem 'raider_reporter', '~> 1.0'\ngem 'ruby_raider', #{pin}\ngem 'selenium-webdriver'\n")
24
+ write('helpers/driver_helper.rb', "require 'selenium-webdriver'\nrequire 'ruby_raider/runtime'\n")
25
+ write('.gitignore', "raider-results\nspec/examples.txt\n.raider/auth/\n")
26
+ end
27
+
28
+ after { FileUtils.rm_rf(dir) }
29
+
30
+ describe '#init' do
31
+ it 'creates the flow helper, the run recorder and the gitignore lines' do
32
+ selenium_project
33
+
34
+ expect(project.init(out:, err:)).to eq(0)
35
+ expect(File).to exist(File.join(dir, 'flows/flow_helper.rb'))
36
+ expect(File).to exist(File.join(dir, 'flows/support/run.rb'))
37
+ expect(read('.gitignore').lines.map(&:strip)).to include('flow-results/', '.raider/flows/', '.raider/auth/')
38
+ end
39
+
40
+ it 'raises a ~> 4.0 ruby_raider pin to ~> 4.1 and says to bundle' do
41
+ selenium_project
42
+
43
+ project.init(out:, err:)
44
+
45
+ expect(read('Gemfile')).to include("gem 'ruby_raider', '~> 4.1'")
46
+ expect(out.string).to include('Run bundle install')
47
+ end
48
+
49
+ it 'keeps a pin that already allows flows' do
50
+ selenium_project(pin: "'~> 4.2'")
51
+
52
+ project.init(out:, err:)
53
+
54
+ expect(read('Gemfile')).to include("gem 'ruby_raider', '~> 4.2'")
55
+ expect(out.string).not_to include('Run bundle install')
56
+ end
57
+
58
+ it 'asks to check a ruby_raider line it cannot raise' do
59
+ selenium_project(pin: "'4.0.1'")
60
+
61
+ expect(project.init(out:, err:)).to eq(0)
62
+ expect(err.string).to include('Check that the ruby_raider line in your Gemfile allows 4.1 or later')
63
+ end
64
+
65
+ it 'is idempotent' do
66
+ selenium_project
67
+ project.init(out:, err:)
68
+ helper = read('flows/flow_helper.rb')
69
+ second = StringIO.new
70
+
71
+ expect(project.init(out: second, err:)).to eq(0)
72
+ expect(second.string).to eq("Flows already set up\n")
73
+ expect(read('flows/flow_helper.rb')).to eq(helper)
74
+ expect(read('.gitignore').scan('flow-results/').size).to eq(1)
75
+ end
76
+
77
+ it 'never overwrites a flow helper the user changed' do
78
+ selenium_project
79
+ write('flows/flow_helper.rb', "# mine\n")
80
+
81
+ project.init(out:, err:)
82
+
83
+ expect(read('flows/flow_helper.rb')).to eq("# mine\n")
84
+ end
85
+
86
+ it 'adds a newline before the gitignore lines when the file does not end with one' do
87
+ selenium_project
88
+ write('.gitignore', 'raider-results')
89
+
90
+ project.init(out:, err:)
91
+
92
+ expect(read('.gitignore')).to eq("raider-results\nflow-results/\n.raider/flows/\n")
93
+ end
94
+
95
+ shared_examples 'a refused project' do
96
+ it 'refuses with exit 2 and writes nothing' do
97
+ expect(project.init(out:, err:)).to eq(2)
98
+ expect(err.string).to eq("Flows need a Selenium web project made with Ruby Raider 4.0 or later\n")
99
+ expect(File).not_to exist(File.join(dir, 'flows'))
100
+ end
101
+ end
102
+
103
+ context 'with a mobile project' do
104
+ before do
105
+ selenium_project
106
+ write('Gemfile', "gem 'appium_lib'\ngem 'ruby_raider', '~> 4.0'\n")
107
+ end
108
+
109
+ it_behaves_like 'a refused project'
110
+ end
111
+
112
+ context 'with a 3.x project' do
113
+ before do
114
+ selenium_project(pin: "'~> 3.15'")
115
+ write('helpers/driver_helper.rb', "require 'selenium-webdriver'\n")
116
+ end
117
+
118
+ it_behaves_like 'a refused project'
119
+ end
120
+
121
+ context 'with a 3.x pin on 4.0 helpers' do
122
+ before { selenium_project(pin: "'~> 3.15'") }
123
+
124
+ it_behaves_like 'a refused project'
125
+ end
126
+
127
+ context 'without a Gemfile' do
128
+ it_behaves_like 'a refused project'
129
+ end
130
+ end
131
+
132
+ describe '#flow_file' do
133
+ before { selenium_project }
134
+
135
+ it 'returns the real path of flows/<name>_flow.rb' do
136
+ write('flows/report_flow.rb', '')
137
+
138
+ expect(project.flow_file('report')).to eq(File.realpath(File.join(dir, 'flows/report_flow.rb')))
139
+ end
140
+
141
+ it 'rejects a name outside the allowed characters' do
142
+ expect { project.flow_file('../x') }.to raise_error(RubyRaider::Flows::Error, /Invalid flow name/)
143
+ end
144
+
145
+ it 'rejects a name longer than 60 characters' do
146
+ expect { project.flow_file('a' * 61) }.to raise_error(RubyRaider::Flows::Error, /Invalid flow name/)
147
+ end
148
+
149
+ it 'reports a missing flow' do
150
+ expect { project.flow_file('nope') }.to raise_error(RubyRaider::Flows::Error, 'No flow nope: flows/nope_flow.rb not found')
151
+ end
152
+
153
+ it 'rejects a flow file that links outside the project' do
154
+ outside = Dir.mktmpdir('outside')
155
+ File.write(File.join(outside, 'evil_flow.rb'), '')
156
+ FileUtils.mkdir_p(File.join(dir, 'flows'))
157
+ File.symlink(File.join(outside, 'evil_flow.rb'), File.join(dir, 'flows/evil_flow.rb'))
158
+
159
+ expect { project.flow_file('evil') }.to raise_error(RubyRaider::Flows::Error, /outside the project/)
160
+ ensure
161
+ FileUtils.rm_rf(outside)
162
+ end
163
+ end
164
+
165
+ describe '#flow_names' do
166
+ it 'lists flow files by name, sorted' do
167
+ write('flows/b_flow.rb', '')
168
+ write('flows/a_flow.rb', '')
169
+ write('flows/flow_helper.rb', '')
170
+
171
+ expect(project.flow_names).to eq(%w[a b])
172
+ end
173
+ end
174
+
175
+ it 'raises a Flows::Error for a missing project folder' do
176
+ expect { described_class.new(File.join(dir, 'missing')) }.to raise_error(RubyRaider::Flows::Error, /Project folder not found/)
177
+ end
178
+ end