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.
- checksums.yaml +4 -4
- data/.github/workflows/e2e_tests.yml +3 -0
- data/README.md +52 -53
- data/RELEASE.md +14 -1
- data/lib/commands/schedule_commands.rb +84 -0
- data/lib/generators/templates/common/git_ignore.tt +1 -0
- data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
- data/lib/ruby_raider/runtime/rspec.rb +1 -0
- data/lib/ruby_raider/runtime/show_browser.rb +53 -0
- data/lib/ruby_raider/runtime.rb +2 -0
- data/lib/ruby_raider.rb +22 -7
- data/lib/utilities/schedules/adapter.rb +22 -0
- data/lib/utilities/schedules/base.rb +27 -0
- data/lib/utilities/schedules/kind.rb +74 -0
- data/lib/utilities/schedules/legacy.rb +196 -0
- data/lib/utilities/schedules/legacy_stub.rb +62 -0
- data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
- data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
- data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
- data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
- data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
- data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
- data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
- data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
- data/lib/utilities/schedules/os_enumerator.rb +30 -0
- data/lib/utilities/schedules/project.rb +70 -0
- data/lib/utilities/schedules/rule.rb +162 -0
- data/lib/utilities/schedules/run_lock.rb +160 -0
- data/lib/utilities/schedules/run_log.rb +122 -0
- data/lib/utilities/schedules/run_report.rb +139 -0
- data/lib/utilities/schedules/run_wrapper.rb +216 -0
- data/lib/utilities/schedules/schedule.rb +89 -0
- data/lib/utilities/schedules/schedule_store.rb +53 -0
- data/lib/utilities/schedules/scheduler.rb +178 -0
- data/lib/utilities/schedules/scheduler_steps.rb +69 -0
- data/lib/utilities/schedules/selection.rb +133 -0
- data/lib/utilities/schedules/spawn.rb +68 -0
- data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/sig/commands/schedule_commands.rbs +12 -0
- data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
- data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
- data/sig/ruby_raider.rbs +4 -0
- data/sig/utilities/schedules/adapter.rbs +5 -0
- data/sig/utilities/schedules/base.rbs +15 -0
- data/sig/utilities/schedules/kind.rbs +32 -0
- data/sig/utilities/schedules/legacy.rbs +45 -0
- data/sig/utilities/schedules/legacy_stub.rbs +15 -0
- data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
- data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
- data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
- data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
- data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
- data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
- data/sig/utilities/schedules/os_enumerator.rbs +12 -0
- data/sig/utilities/schedules/project.rbs +22 -0
- data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
- data/sig/utilities/schedules/run_lock.rbs +29 -0
- data/sig/utilities/schedules/run_log.rbs +33 -0
- data/sig/utilities/schedules/run_report.rbs +34 -0
- data/sig/utilities/schedules/run_wrapper.rbs +45 -0
- data/sig/utilities/schedules/schedule.rbs +27 -0
- data/sig/utilities/schedules/schedule_store.rbs +21 -0
- data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
- data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
- data/sig/utilities/schedules/selection.rbs +36 -0
- data/sig/utilities/schedules/spawn.rbs +10 -0
- data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
- data/spec/commands/schedule_commands_spec.rb +284 -0
- data/spec/integration/content/common_content_spec.rb +8 -2
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +0 -63
- data/spec/runtime/retry_reset_spec.rb +112 -0
- data/spec/runtime/show_browser_project_spec.rb +70 -0
- data/spec/runtime/show_browser_spec.rb +101 -0
- data/spec/utilities/schedules/kind_spec.rb +81 -0
- data/spec/utilities/schedules/legacy_spec.rb +433 -0
- data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
- data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
- data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
- data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
- data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
- data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
- data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
- data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
- data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
- data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
- data/spec/utilities/schedules/os_entry_spec.rb +54 -0
- data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
- data/spec/utilities/schedules/project_spec.rb +125 -0
- data/spec/utilities/schedules/run_lock_spec.rb +143 -0
- data/spec/utilities/schedules/run_log_spec.rb +83 -0
- data/spec/utilities/schedules/run_report_spec.rb +150 -0
- data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
- data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
- data/spec/utilities/schedules/schedule_spec.rb +120 -0
- data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
- data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
- data/spec/utilities/schedules/scheduler_spec.rb +445 -0
- data/spec/utilities/schedules/selection_spec.rb +176 -0
- data/spec/utilities/schedules/spawn_spec.rb +104 -0
- metadata +106 -53
- data/lib/commands/flow_commands.rb +0 -104
- data/lib/generators/templates/flows/flow_helper.tt +0 -42
- data/lib/generators/templates/flows/run.tt +0 -332
- data/lib/utilities/flows/project.rb +0 -170
- data/lib/utilities/flows/run_log.rb +0 -61
- data/lib/utilities/flows/runner.rb +0 -206
- data/lib/utilities/flows/schedule.rb +0 -221
- data/lib/utilities/flows/scheduler.rb +0 -187
- data/sig/commands/flow_commands.rbs +0 -13
- data/sig/utilities/flows/project.rbs +0 -46
- data/sig/utilities/flows/run_log.rbs +0 -22
- data/sig/utilities/flows/runner.rbs +0 -35
- data/spec/commands/flow_commands_spec.rb +0 -128
- data/spec/generators/flow_helper_spec.rb +0 -40
- data/spec/integration/flows/flow_project.rb +0 -62
- data/spec/integration/flows/run_command_spec.rb +0 -91
- data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
- data/spec/utilities/flows/project_spec.rb +0 -178
- data/spec/utilities/flows/run_log_spec.rb +0 -47
- data/spec/utilities/flows/runner_spec.rb +0 -313
- data/spec/utilities/flows/schedule_spec.rb +0 -233
- data/spec/utilities/flows/schedule_store_spec.rb +0 -137
- 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
|