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,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+ require_relative 'command_runner'
6
+ require_relative '../schedule'
7
+
8
+ module RubyRaider
9
+ module Flows
10
+ module Os
11
+ # Linux: a oneshot service and a persistent timer per schedule in the user's systemd folder, enabled
12
+ # with systemctl --user. Words in ExecStart= and Environment= are double-quoted with C escapes; every
13
+ # value has % doubled (specifiers) and ExecStart= also has $ doubled (variable expansion).
14
+ # :reek:TooManyConstants { max_constants: 6 } -- the tool, NOFOLLOW, the escapes, the calendar days, the os_id and path rules
15
+ class Systemd
16
+ SYSTEMCTL = 'systemctl'
17
+ NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
18
+ QUOTED = { '\\' => '\\\\', '"' => '\\"', '%' => '%%' }.freeze
19
+ CALENDAR = { 'hour' => '', 'day' => '', 'weekday' => 'Mon..Fri ' }.freeze
20
+ OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
21
+ # The unit parser strips whitespace at the ends of a value and reads a trailing backslash as "continued"
22
+ UNSAFE_PATH = /\s\z|\\\z/
23
+
24
+ def initialize(runner: CommandRunner.new, home: Dir.home, config_home: ENV.fetch('XDG_CONFIG_HOME', nil))
25
+ @runner = runner
26
+ @home = home
27
+ @config_home = config_home
28
+ end
29
+
30
+ def label = 'systemd'
31
+
32
+ def missed_runs_note = nil
33
+
34
+ def service(schedule)
35
+ env = schedule.env.map { |key, value| "Environment=#{quote("#{key}=#{value}")}" }
36
+ exec = schedule.argv.map { |arg| quote(arg.gsub('$', '$$')) }.join(' ')
37
+ log = specifiers(schedule.log_path)
38
+ <<~UNIT
39
+ [Unit]
40
+ Description=raider flow #{schedule.name}
41
+
42
+ [Service]
43
+ Type=oneshot
44
+ WorkingDirectory=#{specifiers(schedule.working_directory)}
45
+ #{env.join("\n")}
46
+ ExecStart=#{exec}
47
+ StandardOutput=append:#{log}
48
+ StandardError=append:#{log}
49
+ UNIT
50
+ end
51
+
52
+ # Local time; Persistent= runs a missed time once the user manager is back
53
+ def timer(schedule)
54
+ every = schedule.every
55
+ day = CALENDAR.fetch(every) { "#{schedule.on.to_s.capitalize} " }
56
+ hour = every == 'hour' ? '*' : format('%02d', schedule.hour)
57
+ <<~UNIT
58
+ [Unit]
59
+ Description=raider flow #{schedule.name} schedule
60
+
61
+ [Timer]
62
+ OnCalendar=#{day}*-*-* #{hour}:#{format('%02d', schedule.minute)}:00
63
+ Persistent=true
64
+
65
+ [Install]
66
+ WantedBy=timers.target
67
+ UNIT
68
+ end
69
+
70
+ def unit_path(os_id, kind) = File.join(units_dir, "#{checked_os_id(os_id)}.#{kind}")
71
+
72
+ # Writes both units (temp files renamed into place), reloads and enables the timer; a failure leaves no unit behind
73
+ def install(schedule)
74
+ os_id = schedule.os_id
75
+ [schedule.working_directory, schedule.log_path].each { |path| checked_path(path) }
76
+ write({ unit_path(os_id, 'service') => service(schedule), unit_path(os_id, 'timer') => timer(schedule) })
77
+ failure = [systemctl('daemon-reload'), systemctl('enable', '--now', "#{os_id}.timer")].find { |result| !result.success? }
78
+ return true unless failure
79
+
80
+ message = "systemd could not enable #{os_id}: #{Flows.printable(failure.stderr.strip)}"
81
+ raise Error, "#{message}#{discard(os_id)}"
82
+ end
83
+
84
+ # True when a unit file was removed, false when it was already gone
85
+ def remove(os_id)
86
+ paths = %w[service timer].map { |kind| unit_path(os_id, kind) }
87
+ systemctl('disable', '--now', "#{os_id}.timer")
88
+ removed = paths.map { |path| unlink(path) }.any?
89
+ reload = systemctl('daemon-reload')
90
+ raise Error, "systemd could not reload after removing #{os_id}: #{Flows.printable(reload.stderr.strip)}" unless reload.success?
91
+
92
+ removed
93
+ end
94
+
95
+ def present?(os_id) = systemctl('is-enabled', "#{checked_os_id(os_id)}.timer").success?
96
+
97
+ # True when a unit file for the schedule is on disk (no user manager needed)
98
+ def unit_files?(os_id) = %w[service timer].any? { |kind| File.exist?(unit_path(os_id, kind)) }
99
+
100
+ # True when the user's systemd manager answers
101
+ def usable? = systemctl('show-environment').success?
102
+
103
+ private
104
+
105
+ def systemctl(*args) = @runner.call(SYSTEMCTL, '--user', *args)
106
+
107
+ def units_dir
108
+ config = @config_home
109
+ base = config.is_a?(String) && File.absolute_path?(config) ? config : File.join(@home, '.config')
110
+ File.join(base, 'systemd', 'user')
111
+ end
112
+
113
+ def checked_os_id(os_id)
114
+ return os_id if os_id.is_a?(String) && (match = OS_ID.match(os_id)) && Flows.valid_name?(match[1])
115
+
116
+ raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
117
+ end
118
+
119
+ def checked_path(path)
120
+ raise Error, "systemd can not use the path #{Flows.printable(path.inspect)}: it ends in whitespace or a backslash" if UNSAFE_PATH.match?(path)
121
+ end
122
+
123
+ # A word systemd gives back exactly: double quotes, \\ and \" escapes, %% for %
124
+ def quote(text) = %("#{text.gsub(/[\\"%]/, QUOTED)}")
125
+
126
+ # A raw path value: only specifiers apply
127
+ def specifiers(text) = text.gsub('%', '%%')
128
+
129
+ # True when the file was there; a unit that stays behind is an error, not "already gone"
130
+ def unlink(path)
131
+ File.unlink(path)
132
+ true
133
+ rescue Errno::ENOENT
134
+ false
135
+ rescue SystemCallError => e
136
+ raise Error, "Could not remove #{path}: #{e.message}"
137
+ end
138
+
139
+ # Disables the timer, removes both units and reloads; returns what to add to the error when that fails too
140
+ def discard(os_id)
141
+ systemctl('disable', "#{os_id}.timer")
142
+ %w[service timer].each { |kind| unlink(unit_path(os_id, kind)) }
143
+ reload = systemctl('daemon-reload')
144
+ reload.success? ? '' : "; also: reload failed: #{Flows.printable(reload.stderr.strip)}"
145
+ rescue Error => e
146
+ "; also: #{e.message}"
147
+ end
148
+
149
+ # Every temp file is written before any unit is replaced, so a failed write changes nothing
150
+ def write(texts)
151
+ temps = {}
152
+ texts.each { |path, text| temps[path] = temp_file(path, text) }
153
+ temps.each { |path, temp| File.rename(temp, path) }
154
+ rescue SystemCallError => e
155
+ raise Error, "Could not write the systemd units in #{units_dir}: #{e.message}"
156
+ ensure
157
+ temps&.each_value { |temp| FileUtils.rm_f(temp) if File.exist?(temp) }
158
+ end
159
+
160
+ def temp_file(path, text)
161
+ dir = File.dirname(path)
162
+ FileUtils.mkdir_p(dir)
163
+ temp = File.join(dir, ".#{File.basename(path)}.#{SecureRandom.hex(6)}.tmp")
164
+ File.open(temp, File::WRONLY | File::CREAT | File::EXCL | NOFOLLOW, 0o644) do |file|
165
+ file.write(text)
166
+ file.flush
167
+ file.fsync
168
+ file.chmod(0o644)
169
+ end
170
+ temp
171
+ rescue SystemCallError
172
+ FileUtils.rm_f(temp) if temp && File.file?(temp)
173
+ raise
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require_relative 'command_runner'
5
+ require_relative '../schedule'
6
+ require_relative '../next_run'
7
+
8
+ module RubyRaider
9
+ module Flows
10
+ module Os
11
+ # Windows: one task per schedule in the \RubyRaider\ folder, registered from task XML with schtasks.
12
+ # The task runs ruby.exe directly (never cmd.exe or powershell), with the user's own environment and
13
+ # no log file: the outcome lives in runs.jsonl like any flow run. Argument values get Windows CRT
14
+ # quoting, then every value in the XML goes through one XML escaper.
15
+ # Task Scheduler expands %NAME% in Command, Arguments and WorkingDirectory with no escape, so a value
16
+ # holding one is refused before schtasks runs.
17
+ # :reek:TooManyConstants { max_constants: 8 } -- the folder, the XML escapes and namespace, the os_id rule, quoting, globs, %NAME%, day names
18
+ class TaskScheduler
19
+ FOLDER = '\\RubyRaider\\'
20
+ ESCAPES = { '&' => '&amp;', '<' => '&lt;', '>' => '&gt;', '"' => '&quot;', "'" => '&apos;' }.freeze
21
+ NAMESPACE = 'http://schemas.microsoft.com/windows/2004/02/mit/task'
22
+ OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
23
+ # ruby.exe reads its own command line (w32_cmdvector in win32.c): ' quotes too, and a * ? [ { outside
24
+ # '...' (even inside "...") makes it glob the argument
25
+ NEEDS_QUOTES = /[ \t\n\v"']/
26
+ GLOB = /[*?\[{]/
27
+ EXPANDS = /%[^%]+%/
28
+ DAY_NAMES = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze
29
+
30
+ # One argument as ruby.exe reads it back (w32_cmdvector). With a glob character: '...', where nothing
31
+ # is special and nothing globs (and with a ' too: refused). Otherwise "..." when empty or holding
32
+ # whitespace or a quote: backslashes before " (or the closing ") are doubled plus one escapes the ",
33
+ # and an odd run before ' becomes 2n+1 (ruby.exe eats (n+1)/2 of them; an even run is kept as is).
34
+ def self.quote(arg)
35
+ return glob_quote(arg) if arg.match?(GLOB)
36
+ return arg if !arg.empty? && !arg.match?(NEEDS_QUOTES)
37
+
38
+ body = arg.gsub(/(\\*)"/) { "#{Regexp.last_match(1).to_s * 2}\\\"" }
39
+ .gsub(/(\\+)'/) { "#{odd_doubled(Regexp.last_match(1).to_s)}'" }
40
+ .sub(/(\\+)\z/) { Regexp.last_match(1).to_s * 2 }
41
+ "\"#{body}\""
42
+ end
43
+
44
+ def self.glob_quote(arg)
45
+ return "'#{arg}'" unless arg.include?("'")
46
+
47
+ raise Error, "Task Scheduler can not pass #{Flows.printable(arg.inspect)} to ruby.exe: an argument with both ' and * ? [ or { would be globbed"
48
+ end
49
+
50
+ def self.odd_doubled(slashes) = slashes.length.odd? ? "#{slashes * 2}\\" : slashes
51
+
52
+ def self.join(args) = args.map { |arg| quote(arg) }.join(' ')
53
+
54
+ def initialize(runner: CommandRunner.new, clock: Time, schtasks: File.join(ENV.fetch('SystemRoot', 'C:\\Windows'), 'System32', 'schtasks.exe'))
55
+ @runner = runner
56
+ @clock = clock
57
+ @schtasks = schtasks
58
+ end
59
+
60
+ def label = 'Task Scheduler'
61
+
62
+ def task_name(os_id) = "#{FOLDER}#{checked_os_id(os_id)}"
63
+
64
+ # The task XML (a UTF-8 String; install writes it as UTF-16LE with a BOM)
65
+ def task_xml(schedule)
66
+ refuse_expansion(schedule)
67
+ body = [
68
+ element('RegistrationInfo', [text('Description', "Ruby Raider flow #{schedule.name} (#{schedule.rule_text})"),
69
+ text('URI', task_name(schedule.os_id))]),
70
+ element('Triggers', element('CalendarTrigger', trigger(schedule))),
71
+ element('Principals', element('Principal id="Author"', [text('LogonType', 'InteractiveToken'), text('RunLevel', 'LeastPrivilege')])),
72
+ element('Settings', settings),
73
+ element('Actions Context="Author"', element('Exec', action(schedule)))
74
+ ]
75
+ lines = ['<?xml version="1.0" encoding="UTF-16"?>', %(<Task version="1.2" xmlns="#{NAMESPACE}">), *indent(body.flatten), '</Task>']
76
+ "#{lines.join("\n")}\n"
77
+ end
78
+
79
+ # Registers (or replaces) the task from a UTF-16LE temp file, deleted whatever happens
80
+ def install(schedule)
81
+ os_id = schedule.os_id
82
+ file = Tempfile.new(['raider-flow-task', '.xml'], binmode: true)
83
+ file.write("\uFEFF#{task_xml(schedule)}".encode(Encoding::UTF_16LE))
84
+ file.close
85
+ result = schtasks('/Create', '/TN', task_name(os_id), '/XML', file.path.to_s, '/F')
86
+ return true if result.success?
87
+
88
+ raise Error, "Task Scheduler could not create #{os_id}: #{Flows.printable(result.stderr.strip)}"
89
+ ensure
90
+ file&.close!
91
+ end
92
+
93
+ # True when the task was deleted, false when it was already gone. schtasks words "not found" in
94
+ # the system language, so a failed delete is checked against the task list; a list that can not
95
+ # be read, or still shows the task, is an error.
96
+ def remove(os_id)
97
+ name = task_name(os_id)
98
+ result = schtasks('/Delete', '/TN', name, '/F')
99
+ return true if result.success?
100
+
101
+ listing = list
102
+ return false if listing.success? && !listed?(listing, name)
103
+
104
+ raise Error, "Task Scheduler could not delete #{os_id}: #{Flows.printable(result.stderr.strip)}"
105
+ end
106
+
107
+ def present?(os_id)
108
+ name = task_name(os_id)
109
+ listing = list
110
+ return listed?(listing, name) if listing.success?
111
+
112
+ raise Error, "Task Scheduler could not list tasks: #{Flows.printable(listing.stderr.strip)}"
113
+ end
114
+
115
+ private
116
+
117
+ def schtasks(*args) = @runner.call(@schtasks, *args)
118
+
119
+ # Every task, one CSV row each, the full task name in the first field
120
+ def list = schtasks('/Query', '/FO', 'CSV', '/NH')
121
+
122
+ def listed?(listing, name) = listing.stdout.each_line.any? { |line| line.start_with?(%("#{name}",)) }
123
+
124
+ def refuse_expansion(schedule)
125
+ # The joined Arguments, as Task Scheduler expands them: a %...% can span two arguments
126
+ value = [schedule.ruby, self.class.join(schedule.argv.drop(1)), schedule.working_directory].find { |item| item.match?(EXPANDS) }
127
+ return unless value
128
+
129
+ raise Error, "Task Scheduler would expand #{Flows.printable(value[EXPANDS])} as an environment variable in #{Flows.printable(value.inspect)}: " \
130
+ 'remove the %...% from it'
131
+ end
132
+
133
+ def checked_os_id(os_id)
134
+ return os_id if os_id.is_a?(String) && (match = OS_ID.match(os_id)) && Flows.valid_name?(match[1])
135
+
136
+ raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
137
+ end
138
+
139
+ def escape(value) = value.to_s.gsub(/[&<>"']/, ESCAPES)
140
+
141
+ def text(name, value) = ["<#{name}>#{escape(value)}</#{name}>"]
142
+
143
+ # tag may carry fixed attributes ('Principal id="Author"'); the closing tag is its first word
144
+ def element(tag, children) = ["<#{tag}>", *indent(children.flatten), "</#{tag.split.first}>"]
145
+
146
+ def indent(lines) = lines.map { |line| " #{line}" }
147
+
148
+ # Local time without an offset, never in the past (StartWhenAvailable would run a missed start at
149
+ # once): the next run's date, at the rule's own HH:MM so a DST-shifted next run does not move the
150
+ # daily time; an hour rule starts at its next run.
151
+ def trigger(schedule)
152
+ every = schedule.every
153
+ next_run = NextRun.after(schedule, @clock.now)
154
+ hour = every == 'hour' ? next_run.hour : schedule.hour
155
+ start = format('%<date>sT%<hour>02d:%<minute>02d:00', date: next_run.strftime('%Y-%m-%d'), hour: hour, minute: schedule.minute)
156
+ repeat = every == 'hour' ? [element('Repetition', [text('Interval', 'PT1H'), text('Duration', 'P1D')])] : []
157
+ [*repeat, text('StartBoundary', start), text('Enabled', 'true'), *calendar(schedule, every)]
158
+ end
159
+
160
+ def calendar(schedule, every)
161
+ return element('ScheduleByDay', text('DaysInterval', '1')) if %w[hour day].include?(every)
162
+
163
+ days = schedule.days.map { |day| ["<#{DAY_NAMES.fetch(Schedule::DAYS.index(day).to_i)} />"] }
164
+ element('ScheduleByWeek', [element('DaysOfWeek', days), text('WeeksInterval', '1')])
165
+ end
166
+
167
+ def settings
168
+ [text('MultipleInstancesPolicy', 'IgnoreNew'), text('DisallowStartIfOnBatteries', 'false'),
169
+ text('StopIfGoingOnBatteries', 'false'), text('StartWhenAvailable', 'true'), text('Enabled', 'true')]
170
+ end
171
+
172
+ def action(schedule)
173
+ ruby, *args = schedule.argv
174
+ [text('Command', ruby), text('Arguments', self.class.join(args)), text('WorkingDirectory', schedule.working_directory)]
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+
6
+ module RubyRaider
7
+ module Flows
8
+ NAME = /\A[a-z0-9_]{1,60}\z/
9
+ # .raider/flows/env is the secrets file, so no flow may log to .raider/flows/env/
10
+ RESERVED = %w[env].freeze
11
+
12
+ def self.valid_name?(name) = NAME.match?(name.to_s) && !RESERVED.include?(name.to_s)
13
+
14
+ # Text from a run log, safe to print: page text in a step title can't move the cursor or forge lines
15
+ def self.printable(text) = text.to_s.gsub(/[[:cntrl:]]/, '?')
16
+
17
+ # A problem that stops a flow command; exit_code follows `raider flow run` (2 = setup failed).
18
+ class Error < StandardError
19
+ attr_reader :exit_code
20
+
21
+ def initialize(message, exit_code: 2)
22
+ super(message)
23
+ @exit_code = exit_code
24
+ end
25
+ end
26
+
27
+ # A Ruby Raider project that holds flows in flows/<name>_flow.rb.
28
+ class Project
29
+ UNSUPPORTED = 'Flows need a Selenium web project made with Ruby Raider 4.0 or later'
30
+ GITIGNORE_LINES = %w[flow-results/ .raider/flows/].freeze
31
+ TEMPLATES = {
32
+ 'flows/flow_helper.rb' => 'flow_helper.tt',
33
+ 'flows/support/run.rb' => 'run.tt'
34
+ }.freeze
35
+ # gem 'ruby_raider', '~> 4.0' (any quotes); captures the major and minor
36
+ PIN = /^(\s*gem\s+(["'])ruby_raider\2\s*,\s*(["']))~>\s*(\d+)\.(\d+)(?:\.\d+)?(\3)/
37
+ FLOWS_PIN = [4, 1].freeze
38
+
39
+ attr_reader :root
40
+
41
+ def initialize(path)
42
+ @root = File.realpath(path)
43
+ rescue SystemCallError
44
+ raise Error, "Project folder not found: #{path}"
45
+ end
46
+
47
+ def path(relative) = File.join(root, relative)
48
+
49
+ # The flow file for a name, realpath-checked inside the project.
50
+ def flow_file(name)
51
+ raise Error, "Invalid flow name #{name.inspect}: use 1 to 60 of a-z, 0-9 and _ (not env)" unless Flows.valid_name?(name)
52
+
53
+ file = path("flows/#{name}_flow.rb")
54
+ raise Error, "No flow #{name}: flows/#{name}_flow.rb not found" unless File.file?(file)
55
+
56
+ real = File.realpath(file)
57
+ raise Error, "flows/#{name}_flow.rb points outside the project" unless real.start_with?("#{root}#{File::SEPARATOR}")
58
+
59
+ real
60
+ end
61
+
62
+ def flow_names
63
+ Dir.glob(path('flows/*_flow.rb')).map { |file| File.basename(file, '_flow.rb') }.select { |name| Flows.valid_name?(name) }.sort
64
+ end
65
+
66
+ def set_up? = TEMPLATES.keys.all? { |file| File.file?(path(file)) }
67
+
68
+ # nil when flows can be added, otherwise why not
69
+ def unsupported_reason
70
+ gemfile = read('Gemfile')
71
+ return UNSUPPORTED unless gemfile && selenium?(gemfile) && runtime?
72
+ return UNSUPPORTED if old_pin?(gemfile)
73
+
74
+ nil
75
+ end
76
+
77
+ # Returns the exit code: 0 done (or already done), 2 refused.
78
+ def init(out: $stdout, err: $stderr)
79
+ reason = unsupported_reason
80
+ if reason
81
+ err.puts(reason)
82
+ return 2
83
+ end
84
+
85
+ created = write_templates
86
+ updated = add_gitignore_lines
87
+ pinned = pin_gem
88
+ updated << 'Gemfile' if pinned
89
+ report(out, created, updated, pinned)
90
+ warn_unknown_pin(err)
91
+ 0
92
+ end
93
+
94
+ private
95
+
96
+ def template_dir = File.expand_path('../../generators/templates/flows', __dir__)
97
+
98
+ def read(relative)
99
+ file = path(relative)
100
+ File.file?(file) ? File.read(file) : nil
101
+ end
102
+
103
+ def selenium?(gemfile)
104
+ gemfile.match?(/["']selenium-webdriver["']/) && !gemfile.match?(/["']appium_lib["']/)
105
+ end
106
+
107
+ # 4.0 projects load the runtime from the driver helper; 3.x ones used WaitHelper
108
+ def runtime?
109
+ read('helpers/driver_helper.rb').to_s.include?("require 'ruby_raider/runtime'")
110
+ end
111
+
112
+ def old_pin?(gemfile)
113
+ match = gemfile.match(PIN)
114
+ match ? match[4].to_i < FLOWS_PIN.first : false
115
+ end
116
+
117
+ def write_templates
118
+ TEMPLATES.filter_map do |relative, template|
119
+ file = path(relative)
120
+ next if File.exist?(file)
121
+
122
+ FileUtils.mkdir_p(File.dirname(file))
123
+ File.write(file, ERB.new(File.read(File.join(template_dir, template)), trim_mode: '-').result)
124
+ relative
125
+ end
126
+ end
127
+
128
+ def add_gitignore_lines
129
+ file = path('.gitignore')
130
+ content = File.file?(file) ? File.read(file) : ''
131
+ missing = GITIGNORE_LINES - content.lines.map(&:strip)
132
+ return [] if missing.empty?
133
+
134
+ prefix = content.empty? || content.end_with?("\n") ? '' : "\n"
135
+ File.write(file, "#{content}#{prefix}#{missing.join("\n")}\n")
136
+ ['.gitignore']
137
+ end
138
+
139
+ # Raises a ~> 4.0 pin to ~> 4.1 so `bundle exec raider flow` runs a gem that has flows.
140
+ def pin_gem
141
+ gemfile = read('Gemfile').to_s
142
+ match = gemfile.match(PIN)
143
+ return false unless match && ([match[4].to_i, match[5].to_i] <=> FLOWS_PIN).negative?
144
+
145
+ File.write(path('Gemfile'), gemfile.sub(PIN) { "#{match[1]}~> #{FLOWS_PIN.join('.')}#{match[6]}" })
146
+ true
147
+ end
148
+
149
+ # gem 'ruby_raider', '4.0.1' or path: ... isn't raised here, and an old gem has no `raider flow`
150
+ def warn_unknown_pin(err)
151
+ gemfile = read('Gemfile').to_s
152
+ return if gemfile.match?(PIN) || !gemfile.match?(/^\s*gem\s+["']ruby_raider["']/)
153
+
154
+ err.puts("Check that the ruby_raider line in your Gemfile allows #{FLOWS_PIN.join('.')} or later, then run bundle install")
155
+ end
156
+
157
+ def report(out, created, updated, pinned)
158
+ if created.empty? && updated.empty?
159
+ out.puts('Flows already set up')
160
+ return
161
+ end
162
+
163
+ created.each { |file| out.puts("Created #{file}") }
164
+ updated.each { |file| out.puts("Updated #{file}") }
165
+ out.puts('Run bundle install') if pinned
166
+ out.puts('Write a flow in flows/<name>_flow.rb, then run it with: raider flow run <name>')
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyRaider
6
+ module Flows
7
+ # Reads .raider/flows/<name>/runs.jsonl, which the project's flows/support/run.rb appends to.
8
+ class RunLog
9
+ MAX_BYTES = 1024 * 1024
10
+ NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
11
+ NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
12
+ SUMMARY = %w[status finished_at failed_step results_dir].freeze
13
+
14
+ # [{name, file, last: {status, finished_at, failed_step, results_dir} | nil}], the shape `raider flow list --json` prints
15
+ def self.summaries(project)
16
+ project.flow_names.map do |name|
17
+ last = new(project, name).last_result
18
+ { 'name' => name, 'file' => "flows/#{name}_flow.rb", 'last' => last && SUMMARY.to_h { |key| [key, last[key]] } }
19
+ end
20
+ end
21
+
22
+ def initialize(project, name)
23
+ @file = project.path(".raider/flows/#{name}/runs.jsonl")
24
+ end
25
+
26
+ # Every readable line in the last MAX_BYTES, oldest first; a broken line is skipped
27
+ def lines
28
+ tail.lines.filter_map do |line|
29
+ parsed = JSON.parse(line)
30
+ parsed if parsed.is_a?(Hash)
31
+ rescue JSON::ParserError
32
+ nil
33
+ end
34
+ end
35
+
36
+ def last = lines.last
37
+
38
+ # The last run that ran: a busy line only says another run held the lock
39
+ def last_result = lines.reverse.find { |line| line['status'] != 'busy' }
40
+
41
+ def find(run_id) = lines.reverse.find { |line| line['run_id'] == run_id }
42
+
43
+ private
44
+
45
+ # Read through one handle that is not a link, so the checks and the read see the same file
46
+ def tail
47
+ File.open(@file, File::RDONLY | NONBLOCK | NOFOLLOW) do |file|
48
+ stat = file.stat
49
+ return '' unless stat.file?
50
+
51
+ skip = [stat.size - MAX_BYTES, 0].max
52
+ file.seek(skip)
53
+ text = file.read.to_s.force_encoding(Encoding::UTF_8)
54
+ skip.positive? ? text.partition("\n").last : text
55
+ end
56
+ rescue SystemCallError
57
+ ''
58
+ end
59
+ end
60
+ end
61
+ end