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
@@ -1,332 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'English'
4
- require 'fileutils'
5
- require 'json'
6
- require 'time'
7
- require 'tmpdir'
8
-
9
- # One run of a flow: a per-flow lock, a results folder and one line in .raider/flows/<name>/runs.jsonl.
10
- # flows/flow_helper.rb starts it, so `raider flow run`, plain rspec, the desktop app and a schedule all
11
- # leave the same record. Generated by `raider flow init`.
12
- module RaiderFlows
13
- module Run
14
- NAME = /\A[a-z0-9_]{1,60}\z/.freeze
15
- RESERVED = %w[env].freeze # .raider/flows/env is the secrets file, not a flow's log folder
16
- INPUT = /\A[a-z][a-z0-9_]{0,39}\z/.freeze
17
- ENV_KEY = /\A[A-Z_][A-Z0-9_]*\z/.freeze
18
- RUN_ID = /\A[0-9a-f]{16}\z/.freeze
19
- RESULTS_STAMP = /\A\d{8}T\d{6}Z(?:-\d+)?\z/.freeze
20
- TRIGGERS = %w[manual schedule desktop].freeze
21
- KEEP_LINES = 50
22
- KEEP_RESULTS = 20
23
- MAX_RUN_JSON_BYTES = 10 * 1024 * 1024
24
- MAX_ENV_BYTES = 64 * 1024
25
- MIN_SECRET = 4
26
- ENV_FILE = '.raider/flows/env'
27
- NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
28
- NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
29
- ROOT = File.expand_path('../..', __dir__)
30
-
31
- class << self
32
- attr_reader :name, :results_dir
33
-
34
- # At require time, before the driver or the reporter start. Exits 3 when the flow is already running.
35
- def start!
36
- # `rspec --dry-run` runs nothing, so it takes no lock and leaves no record
37
- return if @name || RSpec.configuration.dry_run?
38
-
39
- root = File.realpath(ROOT)
40
- @name = flow_name
41
- @root = root
42
- @started_at = now
43
- @secrets = []
44
- busy! unless lock
45
- stop_on_interrupt
46
- # Registered as soon as the lock is held, so even a failure below leaves its line. It runs after
47
- # RSpec and the reporter finished (run.json is written), whatever way the run ended.
48
- at_exit { finish!(exit_status($ERROR_INFO)) }
49
- @results_dir = new_results_dir
50
- load_env_file
51
- headless
52
- end
53
-
54
- # RSpec would finish the whole flow on a first Ctrl-C and exit! (no run log line) on a second. The first
55
- # one ends the current step instead, so the run is recorded and spec_helper still quits the driver;
56
- # a second one is ignored while that happens, and a third quits at once (no run log line).
57
- def stop_on_interrupt
58
- presses = 0
59
- trap('INT') do
60
- presses += 1
61
- raise Interrupt, 'flow stopped' if presses == 1
62
-
63
- presses == 2 ? say('stopping: press Ctrl-C again to quit at once') : exit!(130)
64
- end
65
- end
66
-
67
- # RAIDER_FLOW_HEADED=1 drops a HEADLESS left in the shell; config.yml headless: true still wins
68
- def headless
69
- if ENV['RAIDER_FLOW_HEADED'] == '1'
70
- ENV.delete('HEADLESS')
71
- else
72
- ENV['HEADLESS'] ||= 'true'
73
- end
74
- end
75
-
76
- # Flow results go to flow-results/<name>/<UTC stamp>, never raider-results, and are never cleaned
77
- def configure_reporter
78
- return unless defined?(RaiderReporter)
79
-
80
- RaiderReporter.configure do |reporter|
81
- reporter.output_dir = results_dir || (@fallback_dir ||= Dir.mktmpdir('raider-flow-'))
82
- reporter.clean_output_dir = false
83
- end
84
- end
85
-
86
- def steps_started!
87
- @steps_started = true
88
- end
89
-
90
- def finish!(code)
91
- return unless @lock
92
-
93
- append(record(code))
94
- prune
95
- rescue StandardError => e
96
- say("could not write the run log: #{e.class}")
97
- ensure
98
- release
99
- end
100
-
101
- private
102
-
103
- def now
104
- Time.now.utc
105
- end
106
-
107
- def say(message)
108
- $stderr.puts("[raider flow] #{message}") # rubocop:disable Style/StderrPuts -- -W0 must not hide these
109
- end
110
-
111
- def flow_name
112
- flows = RSpec.configuration.files_to_run.select { |file| file.end_with?('_flow.rb') }
113
- raise ArgumentError, 'run one flow at a time: rspec flows/<name>_flow.rb' unless flows.one?
114
-
115
- name = File.basename(flows.first, '_flow.rb')
116
- raise ArgumentError, "invalid flow name #{name.inspect}" unless NAME.match?(name) && !RESERVED.include?(name)
117
-
118
- wanted = ENV.fetch('RAIDER_FLOW_NAME', name)
119
- raise ArgumentError, "RAIDER_FLOW_NAME is #{wanted.inspect} but the flow is #{name.inspect}" unless wanted == name
120
-
121
- name
122
- end
123
-
124
- def lock
125
- file = File.join(private_dir('.raider/flows'), "#{name}.lock")
126
- handle = File.open(file, File::RDWR | File::CREAT | NOFOLLOW, 0o600)
127
- return @lock = handle if handle.flock(File::LOCK_EX | File::LOCK_NB)
128
-
129
- handle.close
130
- nil
131
- end
132
-
133
- def release
134
- @lock&.close
135
- @lock = nil
136
- end
137
-
138
- def busy!
139
- append(base_line.merge(finished_at: iso(now), status: 'busy', results_dir: nil))
140
- say("#{name} is already running")
141
- exit 3
142
- end
143
-
144
- # A 0700 folder inside the project that is not a link
145
- def private_dir(relative)
146
- dir = File.join(@root, relative)
147
- FileUtils.mkdir_p(dir)
148
- raise IOError, "#{relative} must be a folder, not a link" unless File.lstat(dir).directory?
149
-
150
- File.chmod(0o700, dir)
151
- dir
152
- end
153
-
154
- def new_results_dir
155
- base = File.join(@root, 'flow-results', name, now.strftime('%Y%m%dT%H%M%SZ'))
156
- dir = base
157
- suffix = 1
158
- dir = "#{base}-#{suffix += 1}" while File.exist?(dir)
159
- FileUtils.mkdir_p(dir)
160
- dir
161
- end
162
-
163
- # KEY=value lines, # comments, no interpolation. Loaded only from a regular file you own with mode 600.
164
- # RAIDER_FLOW_* keys are the wrapper's, so the file can't set them.
165
- def load_env_file
166
- content = read_env_file
167
- return unless content
168
-
169
- content.each_line.with_index(1) do |raw, number|
170
- line = raw.dup.force_encoding(Encoding::UTF_8)
171
- line = line.valid_encoding? ? line.strip : nil
172
- next if line&.empty? || line&.start_with?('#')
173
-
174
- key, value = line&.split('=', 2)&.map(&:strip)
175
- unless value && ENV_KEY.match?(key.to_s) && !key.to_s.start_with?('RAIDER_FLOW_')
176
- say("#{ENV_FILE} line #{number} ignored: expected KEY=value")
177
- next
178
- end
179
- set_env(key.to_s, unquote(value))
180
- end
181
- end
182
-
183
- def read_env_file
184
- File.open(File.join(@root, ENV_FILE), File::RDONLY | NONBLOCK | NOFOLLOW) do |file|
185
- stat = file.stat
186
- return say("ignored #{ENV_FILE}: it must be a regular file you own") unless owned_file?(stat)
187
- return say("ignored #{ENV_FILE}: permissions must be 600") unless private_mode?(stat)
188
-
189
- file.read(MAX_ENV_BYTES).to_s
190
- end
191
- rescue Errno::ENOENT
192
- nil
193
- rescue SystemCallError
194
- say("ignored #{ENV_FILE}: it must be a regular file you own")
195
- end
196
-
197
- # Windows has no POSIX owner or mode bits, so there the folder's ACLs decide who can read the file:
198
- # keep the project under your user profile, or restrict the file with icacls
199
- def owned_file?(stat)
200
- stat.file? && (Gem.win_platform? || stat.uid == Process.uid)
201
- end
202
-
203
- def private_mode?(stat)
204
- Gem.win_platform? || (stat.mode & 0o077).zero?
205
- end
206
-
207
- # A value already in the environment wins; both are kept out of the run log
208
- def set_env(key, value)
209
- ENV[key] = value unless ENV.key?(key)
210
- [value, ENV.fetch(key)].each { |secret| @secrets << secret if secret.length >= MIN_SECRET }
211
- end
212
-
213
- def unquote(value)
214
- quoted = value.length >= 2 && value[0] == value[-1] && ['"', "'"].include?(value[0])
215
- quoted ? value[1...-1].to_s : value
216
- end
217
-
218
- def exit_status(error)
219
- case error
220
- when nil then 0
221
- when SystemExit then error.status
222
- else 1
223
- end
224
- end
225
-
226
- def base_line
227
- {
228
- started_at: iso(@started_at),
229
- attempt: [Integer(ENV.fetch('RAIDER_FLOW_ATTEMPT', '1'), 10, exception: false) || 1, 1].max,
230
- trigger: TRIGGERS.include?(ENV.fetch('RAIDER_FLOW_TRIGGER', nil)) ? ENV.fetch('RAIDER_FLOW_TRIGGER') : 'manual',
231
- run_id: RUN_ID.match?(ENV.fetch('RAIDER_FLOW_RUN_ID', '')) ? ENV.fetch('RAIDER_FLOW_RUN_ID') : nil
232
- }.compact
233
- end
234
-
235
- def record(code)
236
- status = outcome(code)
237
- line = base_line.merge(finished_at: iso(now), status: status, results_dir: relative(results_dir))
238
- line[:failed_step] = failed_step if status == 'failed'
239
- line
240
- end
241
-
242
- def outcome(code)
243
- statuses = RSpec.world.all_examples.map { |example| example.execution_result.status }
244
- return 'completed' if code.zero? && statuses.include?(:passed) && !statuses.include?(:failed)
245
- return 'failed' if @steps_started && statuses.include?(:failed)
246
-
247
- 'setup_error'
248
- end
249
-
250
- # The first failed example: its line in the flow file and the recorded step, when the desktop's sidecar named it
251
- def failed_step
252
- example = RSpec.world.all_examples.find { |each| each.execution_result.status == :failed }
253
- return nil unless example
254
-
255
- number, title = recorded_step(example)
256
- { number: number, title: redact(title || example.description), line: flow_line(example) }.compact
257
- end
258
-
259
- def flow_line(example)
260
- file = example.metadata[:absolute_file_path].to_s
261
- frame = Array(example.exception&.backtrace).find { |each| each.start_with?("#{file}:") }
262
- frame && frame.delete_prefix("#{file}:").to_i
263
- end
264
-
265
- def recorded_step(example)
266
- return nil unless results_dir
267
-
268
- report = File.join(results_dir, 'run.json')
269
- return nil unless File.file?(report) && File.size(report) <= MAX_RUN_JSON_BYTES
270
-
271
- file = example.metadata[:file_path].to_s.delete_prefix('./')
272
- result = JSON.parse(File.read(report)).fetch('results', []).find do |each|
273
- each['file_path'] == file && each['status'] == 'failed' && each['steps'].is_a?(Hash)
274
- end
275
- index = result&.dig('steps', 'failed')
276
- return nil unless index.is_a?(Integer)
277
-
278
- [index + 1, result.dig('steps', 'items', index, 'title')]
279
- rescue StandardError
280
- nil
281
- end
282
-
283
- def redact(text)
284
- @secrets.uniq.sort_by { |secret| -secret.length }.reduce(text.to_s) { |result, secret| result.gsub(secret, '[secret]') }
285
- end
286
-
287
- def append(line)
288
- File.open(log_file, File::WRONLY | File::APPEND | File::CREAT | NOFOLLOW, 0o600) do |file|
289
- file.flock(File::LOCK_EX)
290
- file.write("#{JSON.generate(line)}\n")
291
- end
292
- end
293
-
294
- def prune
295
- File.open(log_file, File::RDWR | NOFOLLOW) do |file|
296
- file.flock(File::LOCK_EX)
297
- lines = file.read.lines
298
- if lines.size > KEEP_LINES
299
- file.rewind
300
- file.truncate(0)
301
- file.write(lines.last(KEEP_LINES).join)
302
- end
303
- end
304
- prune_results
305
- end
306
-
307
- # Only stamp-named folders directly under flow-results/<name>, and never this run's
308
- def prune_results
309
- dir = File.join(@root, 'flow-results', name)
310
- return unless File.directory?(dir) && File.realpath(dir) == dir
311
-
312
- runs = Dir.children(dir).select do |child|
313
- RESULTS_STAMP.match?(child) && File.lstat(File.join(dir, child)).directory?
314
- end
315
- old = runs.sort - runs.sort.last(KEEP_RESULTS) - [results_dir && File.basename(results_dir)]
316
- old.each { |child| FileUtils.rm_rf(File.join(dir, child)) }
317
- end
318
-
319
- def log_file
320
- File.join(private_dir(".raider/flows/#{name}"), 'runs.jsonl')
321
- end
322
-
323
- def relative(path)
324
- path&.delete_prefix("#{@root}/")
325
- end
326
-
327
- def iso(time)
328
- time.iso8601(3)
329
- end
330
- end
331
- end
332
- end
@@ -1,170 +0,0 @@
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
@@ -1,61 +0,0 @@
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